Compare commits

...

58 Commits

Author SHA1 Message Date
雨霖铃 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
LingandRX 42d3fedbaa develop → main: 合并开发分支 (#51)
* 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

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-05-26 15:08:53 +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
LingandRX 92d524d832 fix(tsconfig): remove unused baseUrl and paths configuration (#50) 2026-05-24 08:44:04 +08:00
Ubuntu 63f47fd2b1 Merge branch 'main' into develop 2026-05-23 18:58:15 +08:00
LingandRX 6abdf4add4 fix(ci): sync manifest version from tag before build (#49)
The release workflow was always building with the hardcoded version
from package.json (1.0.0), causing Chrome Web Store to reject uploads
because 1.0.0 equals the already-published 1.0 (trailing zeros ignored).

Added a step to extract the version from the git tag and update
package.json before the build, so the zip contains the correct version.

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-05-23 18:58:08 +08:00
Ubuntu e95ca1297c merge: resolve conflict by taking main version of release.yml 2026-05-23 14:46:44 +08:00
LingandRX a0d6e76cad Merge pull request #48 from LingandRX/hotfix/fix-release-workflow
fix(ci): fix release workflow artifact upload
2026-05-23 14:46:05 +08:00
Ubuntu cc6d414d10 fix(ci): fix release workflow artifact upload
- Copy zip files to fixed directory before upload to avoid glob pattern issues
- Add debug steps to verify output and downloaded artifacts
- Fix Node.js 20 compatibility issue with upload-artifact@v4
2026-05-23 14:43:36 +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
LingandRX 5daa151b5e Refactor GitHub Actions workflow for clarity and efficiency (#46) 2026-05-23 14:27:57 +08:00
LingandRX e93b5c817f Merge pull request #45 from LingandRX/develop
chore: release v1.0.4
2026-05-23 14:15:07 +08:00
雨霖铃 d678f14549 ci(release): fix typecheck command and add missing WXT prepare steps
- Replace non-existent
pm run compile with correct
pm run typecheck
- Add
px wxt prepare before typecheck and test steps (aligned with ci.yml)
- Update AGENTS.md to remove fixed issue note
2026-05-23 14:13:27 +08:00
LingandRX c95e028482 Merge pull request #44 from LingandRX/develop
chore: release v1.0.4
2026-05-23 14:07:13 +08:00
雨霖铃 8ec36ac3b3 test: fix i18n namespace assertions in Base64Converter and QrCode tests
Align test selectors with useLazyTranslation mock which prefixes keys
with namespace (e.g. base64Converter:encode). Also remove obsolete
page title assertions for components that no longer render titles.
2026-05-23 14:04:10 +08:00
雨霖铃 262c5e9255 Merge remote-tracking branch 'origin/main' into develop 2026-05-23 13:39:28 +08:00
雨霖铃 e4993cb0ce docs: update README with current project state
- Add Right Click Restorer feature description
- Remove non-existent pageTheme.ts from project structure
- Update UI components to mention shadcn/ui
- Add missing permissions: contextMenus, alarms, unlimitedStorage
- Clarify permission descriptions
2026-05-23 13:23:53 +08:00
雨霖铃 0d60961a82 docs: update AGENTS.md outdated info
- Remove non-existent config/pageTheme.ts and config/theme.ts
- Update style guide from MUI sx to Tailwind CSS + shadcn/ui
- Replace MUI with Tailwind CSS and shadcn/ui in tech stack
2026-05-23 13:21:50 +08:00
雨霖铃 df3eee8749 refactor: 移除所有页面的 PageHeader 组件
删除 PageHeader 组件及其测试文件,清理相关不再使用的导入和变量。
2026-05-23 13:15:25 +08:00
雨霖铃 cf05df7022 fix(i18n): replace useTranslation with useLazyTranslation in Base64Converter
Fix missing translations by ensuring base64Converter namespace is dynamically loaded.
2026-05-23 13:07:31 +08:00
雨霖铃 e0888c7fb3 refactor: remove remaining config/pageTheme dependencies
- Replace base64ConverterPageStyles.primaryColor with inline text-indigo-800
- Replace qrCodePageStyles.primaryColor with inline text-green-700
- Remove obsolete page-error-boundary design doc
2026-05-23 12:58:35 +08:00
雨霖铃 8b2bca9eef refactor(timestamp): migrate pageTheme constants to Timestamp feature
Remove config/pageTheme.ts and move DATE_FORMAT, ZONES, UnitType, ZoneType
to pages/Timestamp/constants.ts for better feature cohesion.
2026-05-23 12:57:51 +08:00
雨霖铃 6bac88fbc4 feat(right-click-restorer): add unsupported page detection
- Detect and block chrome://, chrome-extension://, about://, edge://, brave:// pages
- Show 'Unsupported' badge with warning icon and disabled button
- Add 'unsupported' and 'unsupportedDesc' i18n keys (zh/en)
- Update useRightClickRestorer hook with isUnsupported state
- Add unit tests for unsupported page behavior
- Update page UI to render conditional unsupported layout
2026-05-23 12:46:20 +08:00
雨霖铃 4c08e17b19 fix(right-click-restorer): prevent domain overflow in domain card
- Add truncate/min-w-0/flex-1 to domain code block for ellipsis
- Add shrink-0 to Badge to prevent compression
- Add gap-2 for consistent spacing
2026-05-23 12:41:42 +08:00
雨霖铃 fa0f1bdcad feat: add right-click restorer for current site
- Add RightClickRestorer page with active unlock trigger
- Content script with dual-world strategy (isolated + main world)
- Monkey patch preventDefault/stopPropagation via background executeScript
- Overlay penetration for pointer-events blocking layers
- Delegated main-world injection to avoid CSP restrictions
- Async message handling for reliable state updates
- i18n translations (zh/en) and unit tests
2026-05-23 12:39:14 +08:00
雨霖铃 2d7bb8fc2c refactor: update background.ts comments for clarity and enhance alarm handling in reload logic; add alarms permission in wxt.config.ts 2026-05-23 10:08:12 +08:00
雨霖铃 a0e6871b16 refactor: rename autoRefresh to reloadAfterClean for clarity and consistency across components 2026-05-23 10:02:02 +08:00
雨霖铃 3adfe95234 refactor: update docs with new features, revised tech stack, and CI changes 2026-05-23 00:17:24 +08:00
LingandRX efbed7cf53 feat: Tailwind CSS/shadcn/ui 迁移 & 右键菜单功能 (#42)
* feat: 添加 contextMenus 权限和消息类型定义

* feat: 实现右键菜单注册与点击分流逻辑

- 新增 utils/contextMenu.ts 封装菜单配置和解析函数
- 在 background.ts 监听 onInstalled 初始化菜单
- 实现 contextMenus.onClicked 点击事件处理
- 分流逻辑:优先发送到侧边栏,否则打开 options 页面
- 添加 contextMenu 单元测试(15个测试用例)
- 更新 vitest.setup.ts 添加 contextMenus mock

* feat: 实现 Content Script 原位轻量提示 UI

- 新增 uiPopover.ts 实现原位弹窗组件(深色主题、自动定位、自动隐藏)
- 新增 contextMenuHandler.ts 处理右键菜单消息
- 时间戳转换:在点击位置显示转换结果
- 文本统计:显示字符/单词/行数/字节统计
- 更新 messageHandler.ts 挂载右键菜单消息监听

* feat: 实现 React 页面层右键菜单数据联动

- 新增 useContextMenuData Hook 处理右键菜单数据传递
- 重构 Jwt/Base64Converter/TextStatistics/QrCode 页面接收右键数据
- RouterProvider 支持从 URL 参数解析右键菜单数据
- 添加 contextMenu/pendingData 存储键到 StorageSchema
- 添加 useContextMenuData 单元测试(12个测试用例)

* test: 添加 background 单元测试与边界情况处理

- 新增 entrypoints/__tests__/background.test.ts (12个测试用例)
- 超长文本截断限制 (>10000字符)
- Base64 内联图片拦截并返回错误提示
- 更新 parseContextMenuClick 返回 ParseResult 格式
- 更新测试匹配新的返回格式
- lint/typecheck/test 全部通过 (579个测试)

* fix: 修复右键菜单跳转到错误页面的问题

- 将 options 页面路径从 '/entrypoints/options/index.html' 修正为 '/options.html'
- WXT 构建后 entrypoints/options/index.html 会输出为根目录的 options.html

* fix: 修复右键菜单跳转目标为 popup 页面

- 将 fallback 页面从 options.html 改为 popup.html
- popup 页面使用 RouterProvider,可以正确处理 URL 参数并跳转到对应功能页面
- options 页面是独立设置页,不支持 URL 参数路由

* fix: 使用 openPopup() 替代 tabs.create() 打开弹窗

- background.ts: 使用 browser.action.openPopup() 打开 popup 弹窗
- 先保存数据到 storage,然后打开 popup
- RouterProvider.tsx: 初始化时检查 storage 中的 pendingData 并跳转到对应页面
- 移除之前错误的 tabs.create() 方式

* fix: 修复右键菜单数据未传递到目标页面的问题

- RouterProvider 不再提前清除 pendingData
- 让目标页面的 useContextMenuData 来消费和清除数据
- 这样确保 TextStatistics 等页面能正确接收选中的文本

* fix: 修复 popup 已打开时右键菜单不生效的问题

- 在 RouterProvider 中添加对 contextMenu/pendingData 的 storage 变化监听
- 当 pendingData 变化时,自动跳转到对应功能页面
- 解决了 openPopup() 只聚焦已有窗口而不触发重新挂载的问题

* feat: 支持右键菜单图片二维码识别

- 在 qrCodeParser.ts 中添加 parseQrCodeFromUrl 函数,支持从图片 URL 解析二维码
- 在 QrCodeToUrlSection 中使用 useContextMenuData 接收右键菜单传递的图片 URL
- 自动下载图片并解析二维码,显示解析结果

* refactor: 暂时移除图片二维码识别功能

- 删除 qrCode-image 右键菜单配置
- 删除 parseQrCodeFromUrl 函数
- 删除 QrCodeToUrlSection 中的 useContextMenuData 相关代码
- 更新相关测试用例

* feat: 支持右键菜单时间戳转换

- 在 useTimestampConverter 中添加 useContextMenuData hook
- 智能识别输入是时间戳还是日期时间字符串
- 自动切换到对应模式并执行转换
- 时间戳自动识别秒/毫秒单位

* fix: 修复右键菜单功能的多个逻辑漏洞

1. 修复时区硬编码问题 - 使用用户选择的时区而非固定 Asia/Shanghai
2. 修复 openPopup() 失败后数据残留 - 失败时清除 storage 中的待处理数据
3. 统一数据过期时间常量 - 导出 CONTEXT_MENU_DATA_EXPIRY_MS 并统一使用
4. 修复 featureKey 类型断言不安全 - 使用映射表处理非常规菜单 ID

* refactor(qrcode): 提取独立组件并添加单元测试

- 创建 pages/QrCode/types.ts 定义状态类型接口
- 提取 QrCodePreview 组件用于二维码预览和操作
- 提取 ImageUploader 组件封装图片上传、拖拽、粘贴逻辑
- 重构 UrlToQrCodeSection 状态提升到父组件
- 重构 QrCodeToUrlSection 通过回调传递解析结果
- 更新主页面 index.tsx 集中管理所有状态
- 为 QrCodePreview 和 ImageUploader 添加单元测试 (23 个用例)

* refactor(qrcode): 引入通用组件并重构双栏布局

- 引入 SwitchButtonGroup 用于模式切换
- 引入 TextInputArea 用于文本输入和结果展示
- 使用 MUI Grid 构建响应式双栏布局
- 实现 generate/parse 模式下的左右面板内容切换
- 修复 iconColor 颜色格式错误,使用 qrCodePageStyles.primaryColor

* refactor(qrcode): 国际化补充、质量保障与错误处理优化

- 补充 i18n 翻译键(generateMode, parseMode, pasteHint 等)
- 创建 useDebounce Hook 实现输入防抖 (200ms)
- 使用 useRef 解决 useEffect 无限循环问题
- 编写 pages/QrCode 单元测试 (7 个用例)
- 优化右键菜单错误处理,改进用户提示
- 运行 603 个测试全部通过

* refactor(qrcode): 组件化重构,采用 Context + Hook 模式

- 创建 QrCodeContext 和 QrCodeProvider 管理共享状态
- 提取 useQrCode Hook 封装所有状态和业务逻辑
- 创建 GeneratePanel 组件处理二维码生成模式
- 创建 ParsePanel 组件处理二维码解析模式
- 简化 index.tsx 为容器组件 (340行 → 47行)
- 删除未使用的旧组件文件 (QrCodeToUrlSection, UrlToQrCodeSection)
- 清理 types.ts 中未使用的类型定义
- 603 个测试全部通过

* fix(qrcode): 固定二维码预览图片尺寸,避免布局抖动

- 设置 QR_PREVIEW_IMAGE 固定尺寸 250x250
- 与 QRious 生成的二维码大小保持一致
- 解决输入内容变化时预览窗口大小跳动问题

* fix(qrcode): 修复切换 tab 后二维码图片失效问题

- 移除 ImageUploader 组件卸载时的预览 URL 释放逻辑
- 在 useQrCode hook 中统一管理预览 URL 生命周期
- 创建新预览 URL 前释放旧的,避免内存泄漏

* fix(qrcode): 固定图片上传区域高度,避免布局抖动

- 将 DROPZONE 的 minHeight: 200 改为 height: 250
- 与二维码预览图片高度保持一致
- 解决上传图片时 div 高度变化问题

* refactor(qrCode): 优化二维码解析功能

- 将'二维码转 URL'改为'二维码转文本'
- 将解析结果的文本预览框的 placeholder 置为空
- 去除手动解析二维码功能,只保留自动解析

* fix(pageHeader): 使用 MUI 主题色替代硬编码颜色,支持暗色模式

* feat(pageHeader): popup 模式下隐藏 PageHeader 组件

* fix(i18n): 统一使用 useLazyTranslation 避免翻译 key 闪烁

* perf: 配置 manualChunks 拆分 vendor chunk,优化打包体积

- 使用 Vite 插件 manualChunksForHtmlOnly 仅对 HTML 多入口构建生效
- 跳过 background/content-script 的 IIFE 构建(不支持 manualChunks)
- 拆分 vendor-react (193KB)、vendor-mui (326KB)、vendor-i18n (55KB)
- 按需加载 vendor-qr (78KB)、vendor-dnd (45KB)、vendor-markdown (41KB)
- PageErrorBoundary chunk 从 454KB 降至 36KB

* chore: upgrade wxt to v0.20.26 and @wxt-dev/module-react to v1.2.2

* chore: upgrade low-risk dependencies

- react: 19.2.3 → 19.2.6
- react-dom: 19.2.3 → 19.2.6
- dayjs: 1.11.19 → 1.11.20
- marked: 18.0.3 → 18.0.4
- prettier: 3.8.1 → 3.8.3
- terser: 5.46.0 → 5.47.1
- i18next: 26.0.8 → 26.2.0
- react-i18next: 17.0.6 → 17.0.8
- globals: 17.2.0 → 17.6.0
- eslint-plugin-react-hooks: 7.0.1 → 7.1.1
- @typescript-eslint/*: 8.54.0 → 8.59.4
- typescript-eslint: 8.54.0 → 8.59.4
- @testing-library/jest-dom: 6.6.0 → 6.9.1
- @testing-library/react: 16.0.0 → 16.3.2
- @testing-library/user-event: 14.5.2 → 14.6.1
- @testing-library/dom: added as peer dependency
- @types/react: 19.2.7 → 19.2.15
- @types/chrome: 0.1.36 → 0.1.42
- @types/webextension-polyfill: 0.12.4 → 0.12.5

* chore: upgrade medium-risk dependencies

- lint-staged: 16.2.7 → 17.0.5
- jsdom: 25.0.0 → 29.1.1
- @vitejs/plugin-react: 4.3.4 → 6.0.2
- vitest: 2.0.0 → 4.1.7
- @vitest/coverage-v8: upgraded to match vitest
- @webext-core/messaging: 2.3.0 → 3.0.1
- eslint: 9.39.2 → 10.4.0
- @eslint/js: added as new dependency
- @types/marked: removed (marked now provides its own types)

Fixes:
- Fix ref update during render in useQrCode.ts
- Fix ref update during render in useStorageCleaner.ts
- Add error cause in jwt.ts decode function
- Add type assertion for mock functions in htmlToMarkdown.test.ts

* chore(deps): 降级 eslint 版本并移除 @eslint/js

* docs: 精简 AGENTS.md,聚焦高信号信息

* test: 修复测试中的 act() 警告和 clipboard mock 问题

- TextInputArea: 使用 userEvent 替代 fireEvent,用 vi.spyOn 替代 Object.assign mock clipboard
- ImageMode/FileMode: 添加 waitForStorageReady() 等待 useStorageState 异步初始化

* perf: 优化首屏加载,快照有效时跳过骨架屏

- isLoaded 初始值根据 localStorage 快照是否存在决定
- 后续访问直接渲染真实 DOM,无需等待 chrome.storage 异步加载
- loadInitialData 仍在后台静默执行确保数据同步

* 引入 Tailwind CSS 和 shadcn/ui

* 替换 Timestamp 页面图标为 lucide-react 的 Clock 图标

* 用 Tailwind CSS 重写 Timestamp 页面的 MUI 基础排版组件

* 用 Tailwind CSS 重构 ResultView 和 LiveClock 组件

* 用 Tailwind CSS 重写 Dashboard/index.tsx 和 ToolCard.tsx

* 用 Tailwind CSS 重写 TextStatistics 页面

* 用 Tailwind CSS 重写 Base64Converter 页面

* 用 Tailwind CSS 重写 QrCode 页面及相关组件

* 用 Tailwind CSS 重写 Jwt 页面

* 用 Tailwind CSS 重写 StorageCleaner 页面,创建 shadcn/ui Dialog 组件

* 用 Tailwind CSS 重写 JsonTools 页面

* 用 Tailwind CSS 重构所有共享组件

* 修复 TypeScript 类型错误:将 MUI sx 语法改成标准 CSS 属性

* 重写基础骨架组件:用纯 Tailwind CSS 替换 MUI Box 组件

* 重写所有页面和组件:用纯 Tailwind CSS 替换 MUI 组件和图标

* 移除 MUI 依赖,简化配置文件,重写 ThemeModeProvider

* 移除 wxt.config.ts 中残留的 MUI chunk 配置

* 为 Dialog 组件添加暗黑模式背景色支持

* fix: 修复暗黑模式样式兼容性

- 在 tailwind.config.js 中启用 darkMode: 'class' 并扩展语义化颜色变量
- 将全项目硬编码颜色(bg-white、text-gray-*、border-gray-* 等)替换为语义化类名
- 修复 popup/index.html 硬编码浅色背景色
- 同步更新相关单元测试中的类名断言

* fix: 修复输入框和按钮在暗色模式下的样式问题

- 为 :root 和 .dark 添加 color-scheme,使浏览器表单控件默认样式适配暗色模式
- 将 bg-primary 搭配 text-white 的按钮改为 text-primary-foreground
- 替换残留的 hover:bg-blue-700、text-blue-500、bg-gray-200 等硬编码颜色
- 同步更新 TextInputArea 单元测试

* fix: 增强 SwitchButtonGroup 和立即转换按钮的视觉层级

- SwitchButtonGroup 选中按钮阴影从 shadow-sm 升级到 shadow-md
- 同步更新单元测试中断言
- Timestamp 立即转换按钮添加 shadow-md 增强边界感

* style: 隐藏所有窗口的滚动条

- 在全局 CSS 中为 html 添加 scrollbar-width: none(Firefox)
- 添加 ::-webkit-scrollbar { display: none }(Chrome/Safari/Opera)

* feat: add sonner dependency for toast notifications

* refactor: remove DomainHeader component and its usage

* refactor: 使用 className 替换 sx/buttonSx 并优化 SwitchButtonGroup 选中动画

* refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast

* refactor: 简化 TextInputArea,移除 showMessage 和 MUI 样式,改用 shadcn 样式和 sonner toast

* refactor: 重构 QrCodePreview 组件,继承 HTMLDiv 属性并统一 shadcn 样式

- 组件改为继承 `React.HTMLAttributes<HTMLDivElement>`,支持外部传入 className 和事件
- 使用 `cn()` 函数合并外部类名
- 空白状态改用 `border border-dashed` 和更浅的 `bg-muted/40` 背景
- 正常状态改为纯白背景柔边圆角容器包裹二维码,确保暗黑模式下黑白对比度
- 下载/复制按钮完全对齐 shadcn 的 Outline 与 Default 样式规范
- 更新测试用例中的 alt 文本为 "QR Code Preview"

* refactor: 将 PageHeader 组件样式从 MUI sx 迁移至 Tailwind 类名,并更新测试

* refactor: 更新PageErrorBoundary的文案和样式以对齐shadcn主题

- 错误提示从“该页面加载失败”改为“该功能运行异常”
- 重试按钮文字从“重试”改为“重新尝试”
- 使用border-destructive, bg-destructive等语义化类名替换硬编码颜色
- 更新对应测试断言

* refactor: 重构 CopyButton 与 RouterContainer 组件,移除 GlobalSnackbar 并统一使用 sonner toast 和 cn 工具函数

* refactor: 移除 MUI sx 样式和未使用的 GlobalSnackbar 引用

* feat: add @radix-ui/react-select dependency

* feat: add Input and Select shadcn UI components

* refactor: 重构 Timestamp 页面使用响应式 Hook 统一状态并全面迁移至 shadcn 样式

* refactor: 将 TextStatistics 页面和 TextInputArea 组件全面迁移至 shadcn 样式并优化布局

* feat: add Badge, Checkbox, Label, and Switch shadcn UI components

* refactor: 全面迁移 StorageCleaner 页面至 shadcn 样式并移除 GlobalSnackbar 依赖

* refactor: 全面重构 JsonTools 页面,采用声明式响应架构并统一 shadcn 样式

* refactor: 全面重构 Jwt 页面,采用防抖输入并统一 shadcn 样式

* refactor: 重构 Dashboard 页面和 ToolCard 组件,全面迁移至 shadcn 样式并优化布局

* refactor: 重构 HtmlToMarkdown 和 MarkdownToHtml 页面,统一 shadcn 样式并优化打印与暗色模式

* refactor: 抽离 useBase64Converter hook 并新增 Base64ConverterSection 组件,统一 Base64 转换页面的 shadcn 样式

* refactor: 重构 QrCode 页面为声明式响应架构并统一 shadcn 样式

* refactor: 优化 Dashboard 布局及 ToolCard 描述文本截断处理

* refactor: 优化 MarkdownToHtml 预览样式,剥离 CSS 变量回退值并增强 iframe 暗色模式自适应

* refactor: 优化 Popover 安全性和交互体验,修复 Context Menu 数据持久化问题并添加 i18n 支持

* refactor: strengthen i18n type safety and fix async Promise handling in language detection

* refactor: extend vitest setup with full chrome/browser mocks and i18n utility mock

* refactor: migrate storage listeners to wxt/browser and refactor provider state management

* refactor: restructure CI/CD pipelines with cached dependencies, separate setup job, and multi-browser builds

* refactor: remove tsc check from lint-staged and reorder lint commands

* refactor: migrate eslint config to tseslint.config with projectService and upgrade React rules

* refactor: use __MSG_ placeholders for i18n and remove postcss config

* refactor: replace __MSG_ placeholders with static strings for extension name and description

* refactor: add React version detection and disable prop-types in eslint; refactor lazy translation tests with proper mock isolation and Chrome/browser stubs

* refactor: use regex matchers in StorageCleanerConfirm tests and add chrome/browser mocks

* refactor: use regex matchers for i18n text and add uniform mocks in test files

* ci: add wxt prepare step to generate .wxt types for typecheck and tests

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
2026-05-23 00:02:43 +08:00
Ubuntu 364672918d ci: add wxt prepare step to generate .wxt types for typecheck and tests 2026-05-22 23:59:56 +08:00
雨霖铃 5e23b29a25 refactor: use regex matchers for i18n text and add uniform mocks in test files 2026-05-22 23:51:16 +08:00
雨霖铃 cc9162f601 refactor: use regex matchers in StorageCleanerConfirm tests and add chrome/browser mocks 2026-05-22 23:45:41 +08:00
雨霖铃 67bafe53d5 refactor: add React version detection and disable prop-types in eslint; refactor lazy translation tests with proper mock isolation and Chrome/browser stubs 2026-05-22 23:43:08 +08:00
109 changed files with 3123 additions and 2065 deletions
+821
View File
@@ -0,0 +1,821 @@
# 代码编写规范
本文档定义了 Testing Tools 浏览器扩展项目的编码规范和最佳实践。所有代码贡献者应遵循这些规范以保持代码库的一致性和可维护性。
## 1. TypeScript 规范
### 1.1 类型定义:`interface` vs `type`
- **`interface`**:用于组件 Props、对象结构、Context 类型等可扩展结构
- **`type`**:用于联合类型、工具类型、不可扩展的类型别名
```typescript
// ✅ interface — 组件 Props / 对象结构
export interface GlobalSnackbarProps {
message: string;
open: boolean;
onClose: () => void;
severity?: SnackbarSeverity;
}
// ✅ interface — 继承 HTML 属性
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
unit: UnitType;
onUseNow: (val: number) => void;
}
// ✅ type — 联合类型
export type ThemeMode = 'light' | 'dark' | 'system';
export type PageType = 'dashboard' | 'timestamp' | 'storageCleaner' | ...;
// ✅ type — 工具类型
export type ResolvedThemeMode = 'light' | 'dark';
```
### 1.2 泛型使用
广泛使用泛型约束,结合 `extends` 进行类型守卫:
```typescript
// ✅ 泛型 + extends 约束
export interface SwitchOption<T extends string | number = string> {
value: T;
label: React.ReactNode;
}
// ✅ 泛型 + StorageSchema 键约束
async get<K extends keyof StorageSchema>(
key: K,
defaultValue?: StorageSchema[K],
): Promise<StorageSchema[K] | undefined> { ... }
// ✅ 泛型 Hook
export const useStorageState = <K extends keyof StorageSchema>(
key: K,
defaultValue: StorageSchema[K],
validator?: (val: unknown) => val is StorageSchema[K],
) => { ... }
```
### 1.3 类型守卫
优先使用类型守卫函数(`val is Type` 谓词),避免 `as` 强转:
```typescript
// ✅ 类型守卫谓词函数
const isValidMode = (v: unknown): v is ThemeMode => VALID_MODES.includes(v as ThemeMode);
const isValidPage = (page: unknown): page is PageType => {
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
};
// ✅ 安全的 as 断言,仅在类型守卫验证后使用
export function isSupportedImageType(mimeType: string): boolean {
return (SUPPORTED_IMAGE_TYPES as readonly string[]).includes(mimeType);
}
```
### 1.4 导出模式
| 场景 | 导出方式 | 示例 |
| ----------- | ------------------------------------------------ | --------------------------------- |
| 页面组件 | `export default function ComponentName()` | `pages/Timestamp/index.tsx` |
| 业务组件 | `const X = React.memo(...)` + `export default X` | `LiveClock.tsx`, `ResultView.tsx` |
| UI 原子组件 | `React.forwardRef(...)` + `export { X }` | `components/ui/button.tsx` |
| 工具函数 | `export function xxx()` | `utils/clipboard.ts` |
| 自定义 Hook | `export function useXxx()` | `utils/useStorageState.ts` |
| 类型/接口 | `export interface` / `export type` | `types/storage.d.ts` |
```typescript
// ✅ 页面组件 — default export
export default function Index() { ... }
// ✅ 需要 memo 的组件 — 箭头函数 + React.memo + displayName
const LiveClock = React.memo(({ ... }: LiveClockProps) => { ... });
LiveClock.displayName = 'LiveClock';
export default LiveClock;
// ✅ UI 组件 — forwardRef + 命名导出
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(...);
Button.displayName = 'Button';
export { Button, buttonVariants };
```
---
## 2. React 组件规范
### 2.1 组件定义方式
- **标准组件**:使用 `function` 声明
- **需要 memo 的组件**:使用箭头函数 + `React.memo`
- **需要 ref 的组件**:使用 `React.forwardRef`
- **错误边界**:使用 Class 组件(React 要求)
```typescript
// ✅ 标准页面组件
export default function Index() { ... }
// ✅ 需要 memo 的组件
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
...
});
LiveClock.displayName = 'LiveClock';
export default LiveClock;
// ✅ 需要 ref 的组件
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
...
});
TextInputArea.displayName = 'TextInputArea';
export default TextInputArea;
// ✅ Class 组件(仅用于 ErrorBoundary
export class ErrorBoundary extends Component<Props, State> { ... }
```
### 2.2 Props 模式
- 使用 `interface` 定义 Props
- 继承 `React.HTMLAttributes` 以支持原生属性透传
- 使用 `Omit` 排除冲突属性
- 解构 `className``...rest props`
```typescript
// ✅ 继承 HTML 属性 + className 透传
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
result: string;
mode: 'ts2dt' | 'dt2ts';
unit: UnitType;
zone: string;
showEmptyPlaceholder?: boolean;
}
// 使用时解构 className 和 rest props
const ResultView = React.memo(({
result, mode, unit, zone,
showEmptyPlaceholder = false,
className, ...props
}: ResultViewProps) => {
return <div className={cn('flex flex-col w-full', className)} {...props}>...</div>;
});
// ✅ Omit 排除冲突属性
export interface TextInputAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange'
> { ... }
```
### 2.3 状态管理
- **本地状态**`useState` + 惰性初始化
- **衍生状态**`useMemo` 响应式计算管线
- **持久化状态**Chrome Storage + `localStorage` 快照
- **全局状态**React Context
```typescript
// ✅ useState + 惰性初始化
const [input, setInput] = useState(() => String(Date.now()));
// ✅ useMemo 响应式计算管线(零延迟,无需手动 convert 按钮)
const conversionPipeline = useMemo(() => {
const rawInput = input.trim();
if (!rawInput) return { result: '', error: '' };
// ... 自动计算结果
}, [input, mode, unit, zone, t]);
// ✅ Chrome Storage 持久化状态
export const useStorageState = <K extends keyof StorageSchema>(
key: K, defaultValue: StorageSchema[K], validator?: ...
) => { ... }
```
### 2.4 副作用模式
- **取消标志**:防止异步竞态
- **ref 回调指针**:保持回调最新避免依赖膨胀
- **事件监听 cleanup**:始终在 cleanup 中移除监听器
- **定时器 cleanup**:始终在 cleanup 中清除定时器
```typescript
// ✅ 取消标志模式 — 防止异步竞态
useEffect(() => {
let cancelled = false;
storageUtil.get(THEME_MODE_KEY, 'system').then((saved) => {
if (cancelled) return;
if (isValidMode(saved)) { ... }
});
return () => { cancelled = true; };
}, [updateResolved]);
// ✅ ref 回调指针 — 保持回调最新避免依赖膨胀
const onUseNowRef = useRef(onUseNow);
useEffect(() => { onUseNowRef.current = onUseNow; }, [onUseNow]);
// ✅ setInterval + cleanup
useEffect(() => {
const tickId = setInterval(tick, 200);
return () => clearInterval(tickId);
}, [unit]);
// ✅ 事件监听 cleanup
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { ... };
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
```
### 2.5 `memo` / `useCallback` / `useMemo` 使用
| 场景 | 使用方式 |
| ---------------------------------- | ------------- |
| 高频渲染组件(列表子项、实时时钟) | `React.memo` |
| 事件处理函数、回调引用 | `useCallback` |
| 响应式计算管线、衍生数据 | `useMemo` |
| 避免重复创建对象/集合 | `useMemo` |
```typescript
// ✅ React.memo — 高频更新组件
const LiveClock = React.memo(({ ... }) => { ... });
const ResultView = React.memo(({ ... }) => { ... });
// ✅ useCallback — 事件处理
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]);
// ✅ useMemo — 避免重复创建集合
const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
```
---
## 3. 导入规范
### 3.1 导入顺序
按来源分组,顺序如下:
1. React 核心
2. 第三方库(图标、UI 库等)
3. 业务 Provider / Context
4. 配置 / 存储
5. i18n
6. 本地页面组件
7. UI 组件
8. 工具函数 / Hook
9. 类型
10. 常量
```typescript
// 1. React 核心
import React, { useEffect, useMemo, useRef, useState } from 'react';
// 2. 第三方库
import { ArrowLeft, ExternalLink, Globe } from 'lucide-react';
import { toast } from 'sonner';
// 3. 业务 Provider
import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider';
// 4. 配置 / 存储
import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
// 5. i18n
import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
// 6. 本地组件
import TextMode from './TextMode';
import { ZONES } from './constants';
// 7. UI 组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button';
// 8. 工具函数 / Hook
import { cn } from '@/lib/utils';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
// 9. 类型
import type { PageType, StorageSchema } from '@/types/storage';
```
### 3.2 路径别名
- `@/` 映射到项目根目录
- **跨目录导入**:使用 `@/` 绝对别名
- **同目录导入**:使用相对路径 `./`
```typescript
// ✅ 绝对别名导入 — 跨目录
import { cn } from '@/lib/utils';
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageSchema } from '@/types/storage';
import { Button } from '@/components/ui/button';
// ✅ 相对导入 — 仅限同目录
import TextMode from './TextMode';
import { ZONES } from './constants';
import { useTimestampConverter } from './useTimestampConverter';
```
---
## 4. 样式规范
### 4.1 `cn()` 工具函数
统一使用 `cn()` 合并 Tailwind 类名(来自 `clsx` + `tailwind-merge`),导入自 `@/lib/utils`
```typescript
import { cn } from '@/lib/utils';
// ✅ 条件类名 + 合并外部 className
<div className={cn(
'flex items-center gap-3 px-3 h-10 rounded-lg border border-border/80 bg-secondary/50',
className, // 外部传入的覆盖
)} {...props}>
// ✅ 错误状态变体
<Input className={cn(
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60',
error && 'border-destructive focus-visible:ring-destructive',
)} />
// ✅ 选中/未选中状态
className={cn(
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
sizeClasses[size],
isSelected
? 'bg-background text-foreground shadow-sm font-semibold'
: 'hover:bg-background/50 hover:text-foreground/80',
buttonClassName,
)}
```
### 4.2 主题 / 暗色模式
使用 shadcn/ui 的 CSS 变量语义化类名,**禁止硬编码颜色值**:
```typescript
// ✅ 语义化颜色 token — 亮/暗模式自适应
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
// ✅ 暗色模式特殊处理
'fixed ... bg-white dark:bg-gray-900 p-6 ...'
// ✅ 需要固定颜色的特殊场景(如二维码白色背景保护)
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
```
**常用语义化 token**
| 用途 | 类名 |
| ---- | ------------------------------------------------------------------ |
| 背景 | `bg-background`, `bg-card`, `bg-muted`, `bg-secondary` |
| 文字 | `text-foreground`, `text-card-foreground`, `text-muted-foreground` |
| 边框 | `border-border`, `border-border/80` |
| 主色 | `text-primary`, `bg-primary`, `border-primary` |
| 危险 | `text-destructive`, `bg-destructive`, `border-destructive` |
### 4.3 响应式设计
移动优先,使用 `sm:` / `md:` / `lg:` 断点:
```typescript
// ✅ Grid 自适应布局
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
// ✅ Dashboard 自动填充网格
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto">
// ✅ 弹性方向切换
<div className="flex flex-col sm:flex-row items-stretch gap-3 w-full">
// ✅ 内边距响应式
<div className="p-4 sm:p-6 space-y-4">
```
---
## 5. 错误处理
### 5.1 工具函数:结果对象模式
工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象:
```typescript
// ✅ 结果对象模式
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
try {
...
return { html, originalLength, htmlLength, hasError: false };
} catch (error) {
return {
html: '', ...
hasError: true,
error: error instanceof Error ? error.message : 'Markdown 解析失败',
};
}
}
```
### 5.2 UI 层:try-catch + Toast
UI 层异步操作使用 try-catch,通过 `sonner``toast` 显示错误:
```typescript
import { toast } from 'sonner';
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
toast.success('复制成功');
} catch {
toast.error('复制失败');
}
}, [value]);
```
### 5.3 Promise 异常隔离
对不关心返回值的异步操作,使用 `void` + `.catch()` 隔离异常:
```typescript
// ✅ void + .catch 模式
void storageUtil.set(THEME_MODE_KEY, next).catch((err) => {
console.error('[Theme Storage Error] Failed to persistent theme state:', err);
});
// ✅ async 函数调用 + .catch
loadConfig().catch(console.error);
```
### 5.4 ErrorBoundary
在应用顶层使用 `ErrorBoundary` 组件捕获子组件树异常:
```typescript
import ErrorBoundary from '@/components/ErrorBoundary';
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
```
---
## 6. 命名规范
### 6.1 文件命名
| 类型 | 命名模式 | 示例 |
| ----------- | -------------------------- | -------------------------------------------------------- |
| 页面目录 | PascalCase | `Timestamp/`, `Base64Converter/`, `StorageCleaner/` |
| 页面入口 | `index.tsx` | `pages/Timestamp/index.tsx` |
| 组件文件 | PascalCase `.tsx` | `TopBar.tsx`, `LiveClock.tsx`, `ResultView.tsx` |
| 自定义 Hook | camelCase `.ts` | `useTimestampConverter.ts`, `useStorageCleaner.ts` |
| 工具函数 | camelCase `.ts` | `chromeStorage.ts`, `base64Converter.ts`, `clipboard.ts` |
| 测试文件 | 与源文件同名 `.test.ts(x)` | `jwt.test.ts`, `SwitchButtonGroup.test.tsx` |
| 类型文件 | camelCase `.d.ts` | `storage.d.ts` |
| 常量文件 | camelCase `.ts` | `constants.ts` |
### 6.2 变量 / 函数命名
```typescript
// ✅ camelCase — 变量、函数、Hook
const conversionPipeline = useMemo(...);
const handleUseNow = useCallback(...);
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
export function textToBase64(text: string): TextToBase64Result { ... }
// ✅ PascalCase — 组件、类型、接口
const LiveClock = React.memo(...);
export interface GlobalSnackbarProps { ... }
export type ThemeMode = 'light' | 'dark' | 'system';
// ✅ SCREAMING_SNAKE_CASE — 常量
const SEARCH_HISTORY_LIMIT = 10;
const THEME_MODE_KEY = 'app/themeMode' as const;
export const MAX_FILE_SIZE = 10 * 1024 * 1024;
export const SUPPORTED_IMAGE_TYPES = [...] as const;
// ✅ 布尔值 — is/has/should 前缀
const isControlled = controlledValue !== undefined;
const isDashboard = currentPage === 'dashboard';
const hasError = true;
```
### 6.3 事件处理函数
使用 `handle` 前缀命名组件内事件处理函数:
```typescript
const handleUseNow = useCallback((now: number) => { ... }, []);
const handleSelectFeature = (feature: FeatureConfig) => { ... };
const handleFileChange = useCallback((file: File) => { ... }, []);
const handleClean = useCallback(async () => { ... }, []);
```
---
## 7. 测试规范
### 7.1 文件组织
测试文件放在源代码同级的 `__tests__/` 目录下:
```
utils/__tests__/jwt.test.ts
utils/__tests__/base64Converter.test.ts
utils/__tests__/useStorageState.test.ts
components/__tests__/SwitchButtonGroup.test.tsx
components/__tests__/ErrorBoundary.test.tsx
pages/Timestamp/__tests__/index.test.tsx
```
### 7.2 describe / it 命名
`describe` 使用模块/函数名,`it` 使用中文描述行为("应该..."):
```typescript
// ✅ 中文 describe + 中文 it
describe('textToBase64', () => {
it('应该编码 ASCII 文本', () => { ... });
it('应该编码中文文本', () => { ... });
it('应该编码空字符串', () => { ... });
});
// ✅ 中文 describe + 中文 it(组件测试)
describe('SwitchButtonGroup 组件', () => {
it('应渲染所有选项按钮', () => { ... });
it('应高亮当前选中的按钮', () => { ... });
it('点击未选中按钮时应触发 onChange 并传入选中值', () => { ... });
});
```
### 7.3 Mock 模式
- 使用 `vi.mock()` 进行模块级 Mock
- 使用 `vi.fn()` 进行函数级 Mock
- 使用 `vi.useFakeTimers()` 控制时间
- **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、i18n、matchMedia 等)
```typescript
// ✅ 模块级 Mock
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn(),
set: vi.fn(() => Promise.resolve()),
},
}));
// ✅ 函数级 Mock + 断言
const handleChange = vi.fn();
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith('b');
// ✅ 定时器 Mock
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
```
### 7.4 断言模式
使用 Testing Library 的 DOM 查询 + Vitest 匹配器:
```typescript
// ✅ 语义化查询
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
// ✅ CSS 类断言
expect(button).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
// ✅ 异步断言
await waitFor(() => {
expect(result.current[0]).toBe(false);
expect(result.current[2]).toBe(true);
});
// ✅ renderHook 测试自定义 Hook
const { result } = renderHook(() => useStorageState('qrCode/urlExpanded', true));
expect(result.current[0]).toBe(true);
```
---
## 8. 自定义 Hook 规范
### 8.1 命名和结构
- 使用 `use` 前缀命名
- 定义返回值接口类型
- 添加 JSDoc 注释
```typescript
// ✅ 完整的 Hook 结构
/**
* 自定义 Hook:处理右键菜单传递的数据
*
* @param options - 配置选项
* @example
* useContextMenuData({ featureKey: 'jwt', onData: handlePayload });
*/
export function useContextMenuData({ featureKey, onData }: UseContextMenuDataOptions): void {
const checkAndConsumeData = useCallback(async () => { ... }, [featureKey, onData]);
useEffect(() => { checkAndConsumeData(); }, [checkAndConsumeData]);
}
// ✅ 返回值接口定义
export interface UseTimestampConverterReturn {
mode: 'ts2dt' | 'dt2ts';
input: string;
result: string;
error: string;
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
setInput: (value: string) => void;
handleUseNow: (now: number) => void;
}
export function useTimestampConverter(): UseTimestampConverterReturn { ... }
```
### 8.2 Hook 存放位置
- **全局通用 Hook**:放在 `utils/` 目录下
- **页面专属 Hook**:与页面组件同目录
```
utils/useStorageState.ts — Chrome Storage 状态持久化
utils/useLazyTranslation.ts — i18n 懒加载
utils/useContextMenuData.ts — 右键菜单数据
utils/useDebounce.ts — 防抖
pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
```
---
## 9. 国际化规范
### 9.1 翻译键格式
- 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
### 9.2 翻译文件结构
```
i18n/locales/{zh,en}/common.json — 全局通用翻译
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
```
### 9.3 使用方式
```typescript
// ✅ 页面组件 — 使用 useLazyTranslation
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function Index() {
const { t } = useLazyTranslation('timestamp');
return <h1>{t('timestamp:title')}</h1>;
}
// ✅ 全局组件 — 使用 useTranslation
import { useTranslation } from 'react-i18next';
export function TopBar() {
const { t } = useTranslation(['common', 'features']);
return <span>{t('common:settings')}</span>;
}
```
### 9.4 添加新翻译
1.`i18n/locales/{zh,en}/features.json` 添加功能标题和描述
2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间
---
## 10. 存储规范
### 10.1 StorageSchema
所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中声明:
```typescript
export interface StorageSchema {
'app/currentRoute': PageType;
'app/popupRoute': PageType;
'app/theme': string;
'app/themeMode': 'light' | 'dark' | 'system';
'storageCleaner/preferences': StorageCleanerPreferences;
// ...
}
```
### 10.2 存储操作
使用 `utils/chromeStorage.ts` 的类型安全封装:
```typescript
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageSchema } from '@/types/storage';
// ✅ 读取
const theme = await storageUtil.get('app/theme', 'default');
// ✅ 写入
await storageUtil.set('app/theme', 'dark');
// ✅ 删除
await storageUtil.remove('app/theme');
```
### 10.3 持久化状态 Hook
使用 `useStorageState` 自动同步 Chrome Storage
```typescript
import { useStorageState } from '@/utils/useStorageState';
const [themeMode, setThemeMode, isInitialized] = useStorageState(
'app/themeMode',
'system',
isValidMode, // 可选的类型守卫
);
```
---
## 11. 文件组织
### 11.1 页面组件结构
```
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
├── LiveClock.tsx # 子组件(可选)
├── ResultView.tsx # 子组件(可选)
└── __tests__/ # 测试文件
└── index.test.tsx
```
### 11.2 目录职责
| 目录 | 职责 |
| ---------------- | ------------------------------------------------------------ |
| `config/` | 应用配置(功能定义、路由映射) |
| `entrypoints/` | 扩展入口点(popup、options、sidepanel、background、content |
| `pages/` | 功能页面组件(懒加载) |
| `components/` | 可复用 UI 组件 |
| `components/ui/` | shadcn/ui 基础组件(button、dialog、select 等) |
| `providers/` | React ContextRouter、Theme 等) |
| `hooks/` | 自定义 React Hooks |
| `utils/` | 工具函数与服务抽象 |
| `types/` | TypeScript 类型声明 |
| `lib/` | 通用工具函数(cn、utils) |
| `i18n/` | 国际化资源 |
| `public/` | 静态资源 |
---
## 12. 代码风格
### 12.1 Prettier 配置
- 行宽:100 字符
- 引号:单引号
- 尾逗号:all
- 换行符:LF
- 分号:是
### 12.2 ESLint 规则
- 禁止使用 `any`(测试文件除外)
- 未使用变量/参数:使用 `_` 前缀(如 `_unused`
- React 19 JSX Runtime:无需手动导入 React
- 使用 `typescript-eslint``projectService: true`
### 12.3 注释规范
- **文件级注释**:使用 JSDoc `@module` 格式(如 `GlobalSnackbar.tsx`
- **函数注释**:使用 JSDoc,包含 `@param``@returns``@example`
- **行内注释**:仅在需要澄清复杂逻辑时使用
- **禁止注释显而易见的代码**
+141
View File
@@ -0,0 +1,141 @@
# Copilot 指令
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
## 核心命令
```bash
npm run dev # Chrome 开发模式(支持 HMR
npm run dev:firefox # Firefox 开发模式
npm run build # Chrome 生产构建
npm run build:firefox # Firefox 生产构建
npm run zip # 打包 Chrome 扩展(.output/*.zip
npm run zip:firefox # 打包 Firefox 扩展
npm run lint # ESLint 检查(--max-warnings=0
npm run typecheck # TypeScript 类型检查(tsc --noEmit
npm run test # 运行全部单元测试(vitest run)
npm run test:watch # Vitest 监视模式
npm run test:coverage # 带覆盖率的测试
```
运行单个测试文件:`npx vitest run path/to/file.test.ts`
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall``wxt prepare` 重新生成 `.wxt/` 类型声明)。
## CI 流水线(GitHub Actions
严格顺序门控,任一步骤失败则终止:
1. **setup** — 安装依赖,缓存 `node_modules`
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
Pre-commit 钩子(`.husky/pre-commit``lint-staged`):
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
2. 同一代码文件:运行 `prettier --write`
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
## 项目架构
### 路由(不使用 React Router
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup``sidepanel``tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
存在三套独立的路由作用域:`app/popupRoute``app/sidepanelRoute``app/tabRoute`,各自维护独立的可见页面列表和页面排序。
### 存储
所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
### 扩展通信
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`
### 页面组件模式
功能页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义(可选)
```
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
- Hook 负责所有状态管理,通过返回值暴露给页面
- 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
### 新功能开发清单
1.`types/storage.d.ts``PageType` 联合类型中添加新成员
2.`config/features.tsx``FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
3.`pages/` 目录创建页面组件(懒加载):
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试
## 关键规范
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
### 浏览器 API
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
### 路径别名
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
### UI 组件
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
- 图标:`lucide-react`
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge
- 主题:使用 shadcn/ui 语义化 token`bg-background``text-foreground``border-border` 等),禁止硬编码颜色
### 代码分割
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd、vendor-markdown),无需手动配置。
### 代码风格
- 禁止使用 `any`(测试文件除外)
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`
- Prettier100 字符宽、单引号、尾逗号 all、LF 换行
- ESLint 使用 `typescript-eslint``projectService: true`
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
### 测试
- 环境:jsdom
- 全局变量:`vitest/globals`describe、it、expect 等无需导入)
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
- `chrome.*` / `browser.*` APIstorage、tabs、runtime、cookies 等)
- `react-i18next`(返回 key 作为翻译)
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
- `window.matchMedia`
- 测试文件命名:`__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
### 国际化(i18n
- 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
### WXT 生成文件
- `.wxt/` 目录由 `postinstall``wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
- 生产构建输出到 `.output/` 目录
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
+9
View File
@@ -90,6 +90,9 @@ jobs:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Run TypeScript type check - name: Run TypeScript type check
run: npm run typecheck run: npm run typecheck
@@ -113,6 +116,9 @@ jobs:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Run tests - name: Run tests
run: npm run test run: npm run test
@@ -144,6 +150,9 @@ jobs:
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令 # 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
- name: Generate WXT types
run: npx wxt prepare
- name: Build Extension (${{ matrix.browser }}) - name: Build Extension (${{ matrix.browser }})
run: | run: |
if npm run | grep -q "build:${{ matrix.browser }}"; then if npm run | grep -q "build:${{ matrix.browser }}"; then
+58 -57
View File
@@ -9,145 +9,147 @@ permissions:
contents: write contents: write
jobs: jobs:
# ── Phase 1: 依赖统一前置基础架构(Infrastructure Stage ─────────────────── # ── Phase 1: 依赖准备 ───────────────────
setup: setup:
name: Prepare Dependencies name: Prepare Dependencies
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- uses: actions/cache@v4
- name: Cache Node Modules
id: cache-nodemodules id: cache-nodemodules
uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-release-v22- ${{ runner.os }}-release-v22-
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
- name: Install dependencies
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
run: npm ci run: npm ci
# ── Phase 2: 全量生产级断言检查(秒级瞬时恢复缓存,安全闭环) ────────────────── # ── Phase 2: 质量检查 ──────────────────
lint: lint:
name: Lint name: Lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: setup needs: setup
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- name: Restore Node Modules - uses: actions/cache@v4
uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- name: Run ESLint - run: npm run lint
run: npm run lint
typecheck: typecheck:
name: TypeScript Check name: TypeScript Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: setup needs: setup
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- name: Restore Node Modules - uses: actions/cache@v4
uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- name: Run TypeScript type check - run: npx wxt prepare
run: npm run compile - run: npm run typecheck
test: test:
name: Unit Tests name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: setup needs: setup
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- name: Restore Node Modules - uses: actions/cache@v4
uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- name: Run tests - run: npx wxt prepare
run: npm run test - run: npm run test
# ── Phase 3: 多端分布式高精打包(Compile & Upload Artifacts ─────────────── # ── Phase 3: 打包 ───────────────────────
build-extension: build-extension:
name: Package Extension name: Package Extension
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [ lint, typecheck, test ] needs: [lint, typecheck, test]
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: '22' node-version: '22'
- name: Restore Node Modules - uses: actions/cache@v4
uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }} key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
# 执行 WXT 高阶打包压缩指令 - name: Sync version from tag
run: |
VERSION="${GITHUB_REF_NAME#v}"
echo "Setting version to $VERSION"
npm version "$VERSION" --no-git-tag-version
- name: Build and Zip Extension - name: Build and Zip Extension
run: | run: |
npm run zip npm run zip
npm run zip:firefox npm run zip:firefox
# 💡 核心自愈补丁:显式将 .output 下打包出的真实生产绝对路径文件, # 🔍 调试:确认 zip 文件位置
# 稳固地上存至 GitHub 的常驻产物箱中进行安全物理隔离,防范后期发布网络崩溃导致产物蒸发! - name: Verify output files
run: |
echo "=== .output/ contents ==="
ls -la .output/ || echo "Not found"
echo "=== All zip files ==="
find . -name "*.zip" -type f
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
- name: Prepare release artifacts
run: |
mkdir -p release-archives
cp .output/*.zip release-archives/ 2>/dev/null || true
echo "Files in release-archives:"
ls -la release-archives/
- name: Upload Extension Artifacts - name: Upload Extension Artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: extension-zips name: extension-zips
path: | path: release-archives/
.output/*.zip if-no-files-found: error
retention-days: 7 retention-days: 7
# ── Phase 4: 独立中央签发发布(Atomic Release Publisher ─────────────────── # ── Phase 4: 发布 ───────────────────────
release: release:
name: Create GitHub Release name: Create GitHub Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build-extension needs: build-extension
steps: steps:
- name: Checkout - uses: actions/checkout@v4
uses: actions/checkout@v4
# 💡 独立下载打包完好的绝对产物包
- name: Download Extension Artifacts - name: Download Extension Artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: extension-zips name: extension-zips
path: release-artifacts path: release-artifacts
# 🔍 调试:确认下载成功
- name: Verify downloaded artifacts
run: |
echo "=== release-artifacts/ ==="
ls -la release-artifacts/
- name: Extract version from tag - name: Extract version from tag
id: version id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
# 💡 最终无风险原子级发布大礼包
- name: Create GitHub Release - name: Create GitHub Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
@@ -156,6 +158,5 @@ jobs:
draft: false draft: false
prerelease: ${{ contains(github.ref_name, '-') }} prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true generate_release_notes: true
# 百分之百精准指向被下载下来的、毫无路径污染风险的 Zip 包实体
files: | files: |
release-artifacts/*.zip release-artifacts/*.zip
+74 -26
View File
@@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript + MUI v7) WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具
## 核心命令 ## 核心命令
@@ -9,6 +9,8 @@ npm run dev # Chrome 开发模式 (HMR)
npm run dev:firefox # Firefox 开发模式 npm run dev:firefox # Firefox 开发模式
npm run build # Chrome 生产构建 npm run build # Chrome 生产构建
npm run build:firefox # Firefox 生产构建 npm run build:firefox # Firefox 生产构建
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
npm run zip:firefox # 打包 Firefox 扩展
npm run lint # ESLint (--max-warnings=0) npm run lint # ESLint (--max-warnings=0)
npm run typecheck # tsc --noEmit npm run typecheck # tsc --noEmit
npm run test # vitest run (单次执行) npm run test # vitest run (单次执行)
@@ -20,64 +22,107 @@ npm run test:coverage # 带覆盖率的测试
## 验证流程 ## 验证流程
CI 执行顺序: `lint → typecheck → test → build` (build 依赖前三者)。 CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败):
Pre-commit hook (lint-staged) 顺序: 1. `setup`(安装依赖、`wxt prepare`
2. 并行运行 `lint``typecheck``test`(三者全部通过才继续)
3. `build`(仅当步骤 2 全部成功时执行)
1. `prettier --write` Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交):
2. `eslint --fix --max-warnings=0`
3. `tsc --noEmit`
提交前确保三者通过。 1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误
2. 同一代码文件:运行 `prettier --write`
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
## WXT 生成文件
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
- 生产构建输出到 `.output/` 目录。
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
## 项目结构 ## 项目结构
``` ```
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源) config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
config/pageTheme.ts # 页面级主题常量
config/theme.ts # MUI 全局主题
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts) entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载) pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件 components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等) providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象 utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明 types/ # TypeScript 类型声明
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json) i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
``` ```
### 页面组件模式
典型功能页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义
```
- 页面组件调用 `useLazyTranslation('featureName')` 获取翻译函数
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
- 子组件可进一步拆分(如 `LiveClock.tsx``ResultView.tsx`
## 关键架构决策 ## 关键架构决策
**路由**: 不使用 React Router。通过 `config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。 **路由**: 不使用 React Router。通过 `config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 Hook **存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`
使用 `utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts` **通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。 **路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-i18n、vendor-qr 等),无需手动配置。
## 测试环境 ## 测试环境
- 环境: jsdom - 环境: jsdom
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock: - Setup 文件: `vitest.setup.ts` 自动 mock:
- `chrome.*` API (storage, tabs, runtime, cookies 等) - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `react-i18next` (返回 key 作为翻译) - `react-i18next` (返回 key 作为翻译)
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
- `window.matchMedia` - `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}` - 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## i18n ## i18n
- 命名空间: `common` (默认), `features` - 命名空间: `common` (默认), `features`
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`) - 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
- 语言: `zh` (默认), `en` - 语言: `zh` (默认), `en`
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` - 翻译文件结构:
- `i18n/locales/{zh,en}/common.json` - 全局通用翻译
- `i18n/locales/{zh,en}/features.json` - 功能模块标题和描述
- `i18n/locales/{zh,en}/{功能名}.json` - 各功能独立翻译(如 timestamp.json, storageCleaner.json 等)
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
- 使用 `useLazyTranslation` hook 加载功能独立翻译,返回 `ns:key` 格式
- 回退策略: 当翻译 key 在目标语言缺失时,回退到默认语言 `zh`;若默认语言也缺失,返回占位格式 `namespace:key` 并在开发模式下记录 warning
## 新功能开发清单 ## 新功能开发清单
1.`types/storage.d.ts` 添加 `PageType` 联合类型 1.`types/storage.d.ts` 添加 `PageType` 联合类型
2.`config/features.tsx``FEATURES` 数组添加配置 2.`config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`pages/` 创建页面组件 (懒加载) 3.`pages/` 创建页面组件 (懒加载)
4.`i18n/locales/{zh,en}/features.json` 添加翻译 - `index.tsx` — UI 组件,使用 `useLazyTranslation` 获取翻译
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4.`i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions` 5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试 6. 添加对应的单元测试
@@ -85,14 +130,17 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
- 禁止使用 `any` (测试文件除外) - 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`) - 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
- 样式: 复杂页面样式放 `config/pageTheme.ts`,简单样式用 MUI `sx` prop - 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- 格式: Prettier (100 字符宽, 单引号, 尾逗号) - UI 组件: 优先使用 `components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
## 技术栈版本 ## 关键外部库(非显而易见的)
- WXT: ^0.20.26 - `@webext-core/messaging` — 扩展消息通信
- React: ^19.2.6 - `@dnd-kit` — 拖拽排序(用于页面顺序管理)
- MUI: ^7.3.8 - `marked` — Markdown 解析
- TypeScript: ^5.9.3 - `qrious` + `qr-scanner` — 二维码生成与解析
- Vitest: ^4.1.7 - `dayjs` — 日期处理(时间戳转换)
- i18next: ^26.2.0 - `sonner` — Toast 通知(替代传统 snackbar
+51 -19
View File
@@ -41,18 +41,41 @@
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示. - **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性. - **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
### 🖱️ 右键恢复工具
- **当前页面检测**: 自动识别当前活动标签页的域名.
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
### 🖼️ 二维码工具 ### 🖼️ 二维码工具
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载. - **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容. - **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
### 🔄 JSON 工具
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
### 🧰 Base64 转换器
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
- **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览.
### 📝 Markdown ↔ HTML 转换
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
## 技术栈 ## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端**: React 19 + TypeScript - **前端**: React 19 + TypeScript
- **UI 组件**: Material UI (MUI) @7.x - **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
- **样式**: Emotion (Styled Components) - **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件) - **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **国际化**: i18next + react-i18next
- **通信**: @webext-core/messaging - **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装) - **存储**: Chrome Storage API (类型安全封装)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
@@ -62,9 +85,8 @@
```text ```text
├── components/ # 可复用 React 组件 ├── components/ # 可复用 React 组件
├── config/ # 应用配置(路由、功能元数据、主题) ├── config/ # 应用配置
── features.tsx # 功能定义与路由映射 ── features.tsx # 功能定义与路由映射
│ └── pageTheme.ts # 各功能页面的视觉风格配置
├── entrypoints/ # 扩展程序入口点 ├── entrypoints/ # 扩展程序入口点
│ ├── popup/ # 点击图标弹出的主界面 │ ├── popup/ # 点击图标弹出的主界面
│ ├── options/ # 扩展程序设置页面 │ ├── options/ # 扩展程序设置页面
@@ -72,10 +94,13 @@
│ ├── background.ts # 后台 Service Worker │ ├── background.ts # 后台 Service Worker
│ └── content.ts # 网页注入脚本 │ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件 ├── pages/ # 各功能模块的页面组件
├── providers/ # 全局状态提供者 (Router, Snackbar 等) ├── providers/ # 全局状态提供者 (Router, Theme 等)
├── types/ # TypeScript 类型声明 ├── hooks/ # 自定义 React Hooks
├── utils/ # 工具函数与服务抽象 ├── utils/ # 工具函数与服务抽象
├── public/ # 静态资源 (图标、 manifest 资源等) ├── types/ # TypeScript 类型声明
├── lib/ # 通用工具函数 (cn, utils 等)
├── i18n/ # 国际化资源
├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置 ├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理 └── package.json # 项目元数据与依赖管理
``` ```
@@ -89,15 +114,20 @@
### 常用命令 ### 常用命令
| 命令 | 说明 | | 命令 | 说明 |
| ----------------------- | -------------------------------- | | ----------------------- | --------------------------------- |
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR | | `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
| `npm run dev:firefox` | 启动 Firefox 开发模式 | | `npm run dev:firefox` | 启动 Firefox 开发模式 |
| `npm run build` | 构建 Chrome 生产版本 | | `npm run build` | 构建 Chrome 生产版本 |
| `npm run compile` | 执行 TypeScript 类型检查 | | `npm run build:firefox` | 构建 Firefox 生产版本 |
| `npm run lint` | 执行 ESLint 代码规范检查 | | `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
| `npm run test` | 运行单元测试 | | `npm run zip:firefox` | 打包 Firefox 扩展 |
| `npm run test:coverage` | 生成测试覆盖率报告 | | `npm run typecheck` | 执行 TypeScript 类型检查 |
| `npm run lint` | 执行 ESLint 代码规范检查 |
| `npm run test` | 运行单元测试 |
| `npm run test:coverage` | 生成测试覆盖率报告 |
运行单个测试: `npx vitest run path/to/file.test.ts`
### 自动化流程 ### 自动化流程
@@ -110,12 +140,14 @@
本扩展根据功能需要申请了以下权限: 本扩展根据功能需要申请了以下权限:
- `storage`: 存储用户设置工具配置. - `storage` & `unlimitedStorage`: 存储用户设置工具配置及大量数据.
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据. - `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
- `scripting`: 在网页中执行清理脚本. - `scripting`: 在网页中执行清理和右键恢复脚本.
- `cookies`: 管理和清理网站 Cookie. - `cookies`: 管理和清理网站 Cookie.
- `sidePanel`: 支持在浏览器侧边栏中运行. - `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能. - `clipboardWrite`: 提供一键复制功能.
- `contextMenus`: 注册右键菜单,支持快捷操作.
- `alarms`: 管理后台定时任务(如清理后自动刷新).
## 浏览器支持 ## 浏览器支持
+17 -37
View File
@@ -1,26 +1,25 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import { copyTextToClipboard } from '@/utils/clipboard'; import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数 import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等 interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
text: string; text: string;
tooltip?: string; tooltip?: string;
size?: 'small' | 'medium' | 'large';
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
} }
export const CopyButton: React.FC<CopyButtonProps> = ({ export const CopyButton: React.FC<CopyButtonProps> = ({
text, text,
tooltip = '复制', tooltip,
size = 'small',
variant = 'ghost', variant = 'ghost',
size = 'icon',
className, className,
...props ...props
}) => { }) => {
const { t } = useTranslation('common');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -31,53 +30,34 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
}, []); }, []);
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => { const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件 e.stopPropagation();
if (!text) { if (!text) {
toast.error('无内容可复制'); toast.error(t('messages.copyEmpty'));
return; return;
} }
const success = await copyTextToClipboard(text); const success = await copyTextToClipboard(text);
if (success) { if (success) {
toast.success('复制成功'); toast.success(t('messages.copySuccess'));
setCopied(true); setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500); timerRef.current = setTimeout(() => setCopied(false), 1500);
} else { } else {
toast.error('复制失败'); toast.error(t('messages.copyError'));
} }
}; };
// 4. 将控制尺寸的类名标准化
const sizeClasses = {
small: 'h-8 w-8 text-xs',
medium: 'h-10 w-10 text-sm',
large: 'h-12 w-12 text-base',
};
// 5. 映射 shadcn 的底层通用 Variant 类名
const variantClasses = {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
};
return ( return (
<button <button
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
title={tooltip} title={tooltip ?? t('buttons.copy')}
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
className={cn( className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', buttonVariants({ variant, size }),
sizeClasses[size], copied &&
copied 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色 className,
: variantClasses[variant],
className, // 允许外部直接传入 text-red-500 等覆盖样式
)} )}
{...props} {...props}
> >
+2 -2
View File
@@ -10,7 +10,7 @@
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter'; import { formatFileSize } from '@/utils/base64Converter';
import { useTranslation } from 'react-i18next'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
interface DecodeResultPaperProps { interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */ /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
@@ -38,7 +38,7 @@ export default function DecodeResultPaper({
onDownload, onDownload,
children, children,
}: DecodeResultPaperProps) { }: DecodeResultPaperProps) {
const { t } = useTranslation('base64Converter'); const { t } = useLazyTranslation('base64Converter');
return ( return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30"> <div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
+19 -17
View File
@@ -1,8 +1,9 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
interface Props { interface Props extends WithTranslation {
children: ReactNode; children: ReactNode;
} }
@@ -11,10 +12,7 @@ interface State {
error: Error | null; error: Error | null;
} }
/** class ErrorBoundaryBase extends Component<Props, State> {
* 错误边界组件:捕获子组件树中的 JavaScript 错误
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { state: State = {
hasError: false, hasError: false,
error: null, error: null,
@@ -39,29 +37,32 @@ export class ErrorBoundary extends Component<Props, State> {
}; };
render() { render() {
const { t } = this.props;
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="mt-16 mx-auto max-w-md"> <div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50"> <div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" /> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<h2 className="text-xl font-extrabold text-red-600 mb-2"></h2> <AlertCircle className="h-8 w-8" />
<p className="text-sm text-muted-foreground mb-6"> </div>
<h2 className="text-xl font-extrabold text-destructive mb-2">
</p> {t('errorBoundary.title')}
</h2>
<p className="text-sm text-muted-foreground mb-6">{t('errorBoundary.description')}</p>
{this.state.error && ( {this.state.error && (
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto"> <div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700"> <pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
{this.state.error.toString()} {this.state.error.toString()}
</pre> </pre>
</div> </div>
)} )}
<Button <Button
variant="default" variant="destructive"
onClick={this.handleReset} onClick={this.handleReset}
className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white" className="rounded-lg font-bold shadow-sm"
> >
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t('errorBoundary.refresh')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -72,4 +73,5 @@ export class ErrorBoundary extends Component<Props, State> {
} }
} }
export const ErrorBoundary = withTranslation('common')(ErrorBoundaryBase);
export default ErrorBoundary; export default ErrorBoundary;
+4
View File
@@ -6,6 +6,10 @@
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态 * 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式 * 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
* *
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
*
* @module GlobalSnackbar * @module GlobalSnackbar
* @version 1.1.0 * @version 1.1.0
* *
+2 -2
View File
@@ -41,7 +41,7 @@ const ImageUploader = ({
[onFileChange, onPreviewUrlChange], [onFileChange, onPreviewUrlChange],
); );
const handleClearFile = () => { const handleClearFile = useCallback(() => {
if (previewUrl) { if (previewUrl) {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
@@ -50,7 +50,7 @@ const ImageUploader = ({
severity: 'success', severity: 'success',
autoHideDuration: 1000, autoHideDuration: 1000,
}); });
}; }, [previewUrl, onClearFile, showMessage, t]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
+10 -21
View File
@@ -1,8 +1,9 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import { Component, ErrorInfo, ReactNode } from 'react';
import { withTranslation, type WithTranslation } from 'react-i18next';
import { AlertCircle, RefreshCw } from 'lucide-react'; import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
interface Props { interface Props extends WithTranslation {
children: ReactNode; children: ReactNode;
resetKey?: string | number; resetKey?: string | number;
} }
@@ -12,11 +13,7 @@ interface State {
error: Error | null; error: Error | null;
} }
/** class PageErrorBoundaryBase extends Component<Props, State> {
* 页面级错误边界组件:捕获子组件树中的 JavaScript 错误
* 完美适配 shadcn/ui 语义化主题与暗黑模式
*/
export class PageErrorBoundary extends Component<Props, State> {
state: State = { state: State = {
hasError: false, hasError: false,
error: null, error: null,
@@ -41,26 +38,22 @@ export class PageErrorBoundary extends Component<Props, State> {
}; };
render() { render() {
const { t } = this.props;
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200"> <div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
{/*
1. 适配暗黑模式的容器设计:
不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5
并在黑夜模式下会自动转为深红底色,绝不刺眼。
*/}
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm"> <div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
{/* 2. 状态符号改用标准的 text-destructive 语义色 */}
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-6 w-6" /> <AlertCircle className="h-6 w-6" />
</div> </div>
<h3 className="text-base font-semibold text-foreground mb-1.5"></h3> <h3 className="text-base font-semibold text-foreground mb-1.5">
{t('pageErrorBoundary.title')}
</h3>
<p className="text-xs text-muted-foreground mb-5"> <p className="text-xs text-muted-foreground mb-5">
{t('pageErrorBoundary.description')}
</p> </p>
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
{this.state.error && ( {this.state.error && (
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40"> <div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700"> <pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
@@ -69,11 +62,6 @@ export class PageErrorBoundary extends Component<Props, State> {
</div> </div>
)} )}
{/*
4. 严谨调用 shadcn 原子 Button
去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。
它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。
*/}
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -81,7 +69,7 @@ export class PageErrorBoundary extends Component<Props, State> {
className="font-medium shadow-sm" className="font-medium shadow-sm"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{t('errorBoundary.retry')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -92,4 +80,5 @@ export class PageErrorBoundary extends Component<Props, State> {
} }
} }
export const PageErrorBoundary = withTranslation('common')(PageErrorBoundaryBase);
export default PageErrorBoundary; export default PageErrorBoundary;
-104
View File
@@ -1,104 +0,0 @@
import { ReactNode, useMemo } from 'react';
import { getEntryPointType } from '@/config/features';
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
/** 要显示的图标组件 */
icon: ReactNode;
/**
* 图标的颜色,支持:
* 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐
* 2. 原生颜色值 (如 '#3b82f6')
*/
iconColor?: string;
/** 主标题文本 */
title: string;
/** 副标题文本(可选) */
subtitle?: string;
/** 在标题右侧显示的徽章/标签组件(可选) */
badge?: ReactNode;
/** 覆盖图标容器的类名 */
iconClassName?: string;
/** 覆盖主标题的类名 */
titleClassName?: string;
/** 覆盖副标题的类名 */
subtitleClassName?: string;
}
export default function PageHeader({
icon,
iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6"
title,
subtitle,
badge,
iconClassName,
titleClassName,
subtitleClassName,
className,
...props
}: PageHeaderProps) {
const entryPointType = useMemo(() => getEntryPointType(), []);
// 扩展环境判断:如果是 popup 形式则不渲染头部
if (entryPointType === 'popup') {
return null;
}
// 判断传入的是否是 Hex/RGB 等原生颜色值
const isRawColor =
iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl');
return (
<div className={cn('flex items-center gap-3 mb-6', className)} {...props}>
{/* 图标容器 */}
<div
className={cn(
'p-2 rounded-lg flex items-center justify-center shrink-0',
// 如果不是原生颜色,直接当作 Tailwind 类名注入
!isRawColor && iconColor,
iconClassName,
)}
style={
isRawColor
? {
color: iconColor,
// 使用 CSS inline 变量或 color-mix 安全处理透明度,不再暴力拼接 "15"
backgroundColor: `color-mix(in srgb, ${iconColor} 8%, transparent)`,
}
: undefined
}
>
{/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */}
<div className="[&>svg]:h-5 [&>svg]:w-5">{icon}</div>
</div>
{/* 标题区域 */}
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
{/* 标题行(含徽章) */}
<div className="flex justify-between items-center gap-2">
<h1
className={cn(
'text-base font-extrabold tracking-tight leading-tight text-foreground truncate',
titleClassName,
)}
>
{title}
</h1>
{badge && <div className="shrink-0">{badge}</div>}
</div>
{/* 副标题 - 使用 p 标签(block)保证换行 */}
{subtitle && (
<p
className={cn(
'text-xs font-semibold text-muted-foreground truncate',
subtitleClassName,
)}
>
{subtitle}
</p>
)}
</div>
</div>
);
}
+6 -16
View File
@@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数 import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件 // 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> { interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -59,27 +60,16 @@ const QrCodePreview = ({
/> />
</div> </div>
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
<div className="flex w-full gap-2 mt-5"> <div className="flex w-full gap-2 mt-5">
{/* 下载按钮:使用标准的次要按钮风格 (Outline) */} <Button variant="outline" size="sm" onClick={onDownload} className="flex-1">
<button
type="button"
onClick={onDownload}
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md border border-input bg-background shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Download className="w-4 h-4 text-muted-foreground" /> <Download className="w-4 h-4 text-muted-foreground" />
<span className="truncate">{t('qrCode:downloadButton')}</span> <span className="truncate">{t('qrCode:downloadButton')}</span>
</button> </Button>
{/* 复制按钮:使用标准的主要行动按钮风格 (Default) */} <Button variant="default" size="sm" onClick={onCopy} className="flex-1">
<button
type="button"
onClick={onCopy}
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<Copy className="w-4 h-4" /> <Copy className="w-4 h-4" />
<span className="truncate">{t('qrCode:copyQrButton')}</span> <span className="truncate">{t('qrCode:copyQrButton')}</span>
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
+27
View File
@@ -0,0 +1,27 @@
# components/
通用业务组件目录,存放跨页面复用的 UI 组件,与具体工具页面解耦。
## 组件列表
| 组件 | 用途 |
| ----------------------- | ------------------------------------------------------------------------------ |
| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 |
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard``toast` 反馈 |
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 |
| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 |
| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 |
| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard``tool` 两种变体,用于 Suspense fallback |
## 使用约定
- 优先使用 `components/ui/` 下的 shadcn/ui 基础组件
- 组件使用 `cn()` 合并 Tailwind 类名,支持 `className` 透传
- 需要 memo 优化的组件使用 `React.memo` + `displayName`
- 需要 ref 转发的组件使用 `React.forwardRef` + `displayName`
+5 -5
View File
@@ -1,6 +1,7 @@
import { FEATURES, getEntryPointType } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { Suspense, useMemo } from 'react'; import { Suspense, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数 import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
@@ -8,15 +9,14 @@ import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter(); const { currentPage, isLoaded } = useRouter();
const { t } = useTranslation('common');
// 2. 稳定的动态动画类名映射 // 2. 稳定的动态动画类名映射
const animationClass = useMemo(() => { const animationClass = useMemo(() => {
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
}, [currentPage]); }, [currentPage]);
const entryPointType = useMemo(() => { const entryPointType = getEntryPointType();
return getEntryPointType();
}, []);
// 骨架屏加载状态守卫 // 骨架屏加载状态守卫
if (!isLoaded) { if (!isLoaded) {
@@ -52,9 +52,9 @@ export default function RouterContainer() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" /> <AlertTriangle className="h-6 w-6" />
</div> </div>
<h3 className="text-sm font-semibold text-foreground"></h3> <h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]"> <p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{entryPointType} {t('router.notFoundDescription', { entryPointType })}
</p> </p>
</div> </div>
)} )}
+1 -5
View File
@@ -34,14 +34,10 @@ export default function SwitchButtonGroup<T extends string | number = string>({
large: 'text-base h-11 px-4 py-2 rounded-lg', large: 'text-base h-11 px-4 py-2 rounded-lg',
}; };
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
return ( return (
<div <div
className={cn( className={cn(
// 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间) 'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
containerPadding,
className, className,
)} )}
{...props} {...props}
+10 -21
View File
@@ -1,8 +1,9 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { Copy, X } from 'lucide-react'; import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton';
export type ValidateRule = { export type ValidateRule = {
validator: (value: string) => boolean; validator: (value: string) => boolean;
@@ -183,19 +184,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
onChange?.(''); onChange?.('');
setError(''); setError('');
internalRef.current?.focus(); internalRef.current?.focus();
toast.success('已清空内容'); toast.success(t('textInputArea.cleared'));
onClear?.(); onClear?.();
}, [isControlled, onChange, onClear]); }, [isControlled, onChange, onClear, t]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
toast.success('复制成功');
} catch {
setError('复制失败');
toast.error('复制失败');
}
}, [value]);
const handleAction = useCallback( const handleAction = useCallback(
(action: ToolbarAction) => { (action: ToolbarAction) => {
@@ -284,14 +275,12 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{/* 右侧系统按钮组 */} {/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0"> <div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && ( {allowCopy && value && (
<button <CopyButton
type="button" text={value}
onClick={handleCopy} tooltip={t('textInputArea.copyContent')}
aria-label={t('textInputArea.copyContent')} size="sm"
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="h-7 w-7 p-1"
> />
<Copy className="h-4 w-4" />
</button>
)} )}
{showClear && value && !disabled && !readOnly && ( {showClear && value && !disabled && !readOnly && (
<button <button
+10 -7
View File
@@ -15,6 +15,7 @@ import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features'; import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n'; import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数 import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
@@ -125,17 +126,19 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (selectedIndex >= 0) { if (selectedIndex >= 0 && selectedIndex < totalItems) {
if (searchQuery.trim()) { if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]); handleSelectFeature(searchResults[selectedIndex]);
} else { } else {
const selectedQuery = displayedHistory[selectedIndex]; const selectedQuery = displayedHistory[selectedIndex];
setSearchQuery(selectedQuery); if (selectedQuery) {
setSelectedIndex(-1); setSearchQuery(selectedQuery);
const matched = FEATURES.find( setSelectedIndex(-1);
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery, const matched = FEATURES.find(
); (f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
if (matched) handleSelectFeature(matched); );
if (matched) handleSelectFeature(matched);
}
} }
} else if (searchQuery.trim() && searchResults.length > 0) { } else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]); handleSelectFeature(searchResults[0]);
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
it('应渲染下载按钮', () => { it('应渲染下载按钮', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
}); });
it('应渲染 children 内容', () => { it('应渲染 children 内容', () => {
@@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => {
it('点击下载按钮时应调用 onDownload', () => { it('点击下载按钮时应调用 onDownload', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: 'download' })); fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1); expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => {
describe('按钮状态', () => { describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => { it('文件名为空时下载按钮应禁用', () => {
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />); render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
expect(screen.getByRole('button', { name: 'download' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
}); });
it('文件名不为空时下载按钮应启用', () => { it('文件名不为空时下载按钮应启用', () => {
render(<DecodeResultPaper {...defaultProps} />); render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: 'download' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
}); });
}); });
}); });
+23 -16
View File
@@ -1,7 +1,24 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import ImageUploader from '@/components/ImageUploader'; import ImageUploader from '@/components/ImageUploader';
// 配置多端一致性常驻桩(WXT 规范)
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 1. 规范对齐:挂载标准的 react-i18next 统一桩函数,防止多进程前缀破产
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
// 模拟 URL API // 模拟 URL API
const mockCreateObjectURL = vi.fn(); const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn(); const mockRevokeObjectURL = vi.fn();
@@ -44,8 +61,9 @@ describe('ImageUploader 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => { it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />); render(<ImageUploader {...defaultProps} />);
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument(); // 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument(); expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
}); });
it('当没有选中文件时应显示 ImageIcon', () => { it('当没有选中文件时应显示 ImageIcon', () => {
@@ -59,7 +77,8 @@ describe('ImageUploader 组件', () => {
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />, <ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
); );
expect(screen.getByText('test.png')).toBeInTheDocument(); expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument(); // 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
}); });
it('当选中文件时应显示预览图片', () => { it('当选中文件时应显示预览图片', () => {
@@ -157,18 +176,6 @@ describe('ImageUploader 组件', () => {
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url'); expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
expect(mockOnClearFile).toHaveBeenCalledTimes(1); expect(mockOnClearFile).toHaveBeenCalledTimes(1);
}); });
it('清除文件时应撤销预览 URL', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
});
}); });
describe('粘贴功能', () => { describe('粘贴功能', () => {
-100
View File
@@ -1,100 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
vi.mock('@/config/features', () => ({
getEntryPointType: vi.fn(() => 'sidepanel'),
}));
describe('PageHeader 组件系统', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
const defaultProps: PageHeaderProps = {
icon: <span data-testid="test-icon"></span>,
title: '时间戳转换',
subtitle: 'Unix 毫秒数转换与格式化',
};
describe('PageHeader UI 渲染', () => {
it('应渲染页面标题栏&副标题', () => {
render(<PageHeader {...defaultProps} />);
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
});
it('应渲染图标', () => {
render(<PageHeader {...defaultProps} />);
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
});
it('应渲染自定义图标&图标颜色', () => {
render(
<PageHeader
{...defaultProps}
icon={<span data-testid="custom-icon">X</span>}
iconColor="#FF0000"
/>,
);
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
});
it('应默认使用蓝色作为 primary 色', () => {
render(<PageHeader {...defaultProps} />);
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
expect(iconContainer).toHaveClass('text-blue-500');
});
it('应渲染 badge 组件', () => {
const badge = <span data-testid="test-badge">New</span>;
render(<PageHeader {...defaultProps} badge={badge} />);
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
});
it('应支持自定义 iconClassName', () => {
render(<PageHeader {...defaultProps} iconClassName="custom-icon-class" />);
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
expect(iconContainer).toHaveClass('custom-icon-class');
});
it('应支持自定义 titleClassName', () => {
render(<PageHeader {...defaultProps} titleClassName="custom-title-class" />);
const title = screen.getByText('时间戳转换');
expect(title).toHaveClass('custom-title-class');
});
it('应支持自定义 subtitleClassName', () => {
render(<PageHeader {...defaultProps} subtitleClassName="custom-subtitle-class" />);
const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
expect(subtitle).toHaveClass('custom-subtitle-class');
});
it('应支持自定义 className', () => {
const { container } = render(<PageHeader {...defaultProps} className="custom-page-header" />);
const outerElement = container.firstChild;
expect(outerElement).toHaveClass('custom-page-header');
});
it('无副标题时不渲染副标题区域', () => {
const { container } = render(<PageHeader icon={defaultProps.icon} title="仅标题" />);
const subtitles = container.querySelectorAll('.text-muted-foreground');
expect(subtitles.length).toBe(0);
});
});
describe('PageHeader 入口点隐藏', () => {
it('popup 模式下应返回 null', async () => {
const { getEntryPointType } = await import('@/config/features');
vi.mocked(getEntryPointType).mockReturnValue('popup');
const { container } = render(<PageHeader {...defaultProps} />);
expect(container.innerHTML).toBe('');
});
});
});
+25 -7
View File
@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview'; import QrCodePreview from '@/components/QrCodePreview';
// 💡 1. 规范对齐:在这个测试文件的头部同样挂载统一的 react-i18next 桩函数,
// 与你整个工程的国际化解耦架构完美闭环。
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('QrCodePreview 组件', () => { describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn(); const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn(); const mockOnCopy = vi.fn();
@@ -18,7 +31,8 @@ describe('QrCodePreview 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => { it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />); render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument(); // 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => { it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
@@ -43,7 +57,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument(); // 💡 修复点 3:切换为正则,无缝过检
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => { it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
@@ -54,13 +69,14 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument(); // 💡 修复点 4:切换为正则,无缝过检
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
}); });
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => { it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />); render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument(); expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument(); expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
}); });
}); });
@@ -73,7 +89,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
fireEvent.click(screen.getByText('qrCode:downloadButton')); // 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
fireEvent.click(screen.getByText(/downloadButton/));
expect(mockOnDownload).toHaveBeenCalledTimes(1); expect(mockOnDownload).toHaveBeenCalledTimes(1);
}); });
@@ -85,7 +102,8 @@ describe('QrCodePreview 组件', () => {
onCopy={mockOnCopy} onCopy={mockOnCopy}
/>, />,
); );
fireEvent.click(screen.getByText('qrCode:copyQrButton')); // 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
fireEvent.click(screen.getByText(/copyQrButton/));
expect(mockOnCopy).toHaveBeenCalledTimes(1); expect(mockOnCopy).toHaveBeenCalledTimes(1);
}); });
}); });
@@ -4,6 +4,23 @@ import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConf
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react'; import React from 'react';
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 💡 2. 对齐 react-i18next 的分布式国际化桩
vi.mock('react-i18next', () => ({
useTranslation: vi.fn((ns: string | string[]) => {
const nsArray = Array.isArray(ns) ? ns : [ns];
return {
t: (key: string) => `${nsArray.join(',')}:${key}`,
i18n: { language: 'en' },
ready: true,
};
}),
}));
describe('StorageCleanerConfirm 组件', () => { describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn(); const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn(); const mockOnConfirm = vi.fn();
@@ -36,27 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
describe('渲染测试', () => { describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => { it('open 为 true 时应渲染对话框', () => {
renderComponent(); renderComponent();
expect(screen.getByText('storageCleaner:confirmTitle')).toBeInTheDocument(); // 💡 修复点 3:拥抱模糊正则断言。
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
}); });
it('应显示警告信息', () => { it('应显示警告信息', () => {
renderComponent(); renderComponent();
expect(screen.getByText(/storageCleaner:irreversible/i)).toBeInTheDocument(); expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
}); });
it('应将选中的选项显示为标签', () => { it('应将选中的选项显示为标签', () => {
renderComponent(); renderComponent();
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument(); expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument(); expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument(); expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
}); });
it('应显示取消和确认按钮', () => { it('应显示取消和确认按钮', () => {
renderComponent(); renderComponent();
expect(screen.getByRole('button', { name: /common:buttons.cancel/i })).toBeInTheDocument(); // 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
expect( expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
screen.getByRole('button', { name: /storageCleaner:confirmAction/i }), expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
).toBeInTheDocument();
}); });
}); });
@@ -64,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击取消时应调用 onClose', () => { it('点击取消时应调用 onClose', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /common:buttons.cancel/i })); fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled(); expect(mockOnConfirm).not.toHaveBeenCalled();
}); });
@@ -72,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
it('点击确认时应调用 onConfirm', () => { it('点击确认时应调用 onConfirm', () => {
renderComponent(); renderComponent();
fireEvent.click(screen.getByRole('button', { name: /storageCleaner:confirmAction/i })); fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1); expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled();
}); });
@@ -91,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: partialOptions }); renderComponent({ options: partialOptions });
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument(); expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
expect(screen.getByText('storageCleaner:options.indexedDB')).toBeInTheDocument(); expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
expect(screen.queryByText('storageCleaner:options.sessionStorage')).not.toBeInTheDocument(); expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
expect(screen.queryByText('storageCleaner:options.cookies')).not.toBeInTheDocument(); expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
}); });
it('应处理空选项', () => { it('应处理空选项', () => {
@@ -117,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
describe('对话框行为测试', () => { describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => { it('open 为 false 时不应渲染', () => {
renderComponent({ open: false }); renderComponent({ open: false });
expect(screen.queryByText('storageCleaner:confirmTitle')).not.toBeInTheDocument(); expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
}); });
it('应使用不同选项渲染', () => { it('应使用不同选项渲染', () => {
@@ -132,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
renderComponent({ options: customOptions }); renderComponent({ options: customOptions });
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument(); expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument(); expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
}); });
}); });
}); });
+40
View File
@@ -0,0 +1,40 @@
# components/ui/
shadcn/ui 基础原子组件目录,基于 Radix UI 原语 + Tailwind CSS 实现。
## 组件列表
| 组件 | 用途 |
| -------------- | -------------------------------------------------------------------------------------------------- |
| `button.tsx` | 按钮组件,支持 `default/destructive/outline/secondary/ghost/link` 变体和 `default/sm/lg/icon` 尺寸 |
| `input.tsx` | 标准输入框,统一的 ring/focus 样式 |
| `select.tsx` | 下拉选择组件,包含 Trigger、Content、Item 等子组件 |
| `dialog.tsx` | 对话框组件,包含 Overlay、Content、Header、Footer、Title、Description |
| `checkbox.tsx` | 复选框组件 |
| `label.tsx` | 标签组件 |
| `switch.tsx` | 开关组件 |
| `badge.tsx` | 徽章组件,支持 `default/secondary/destructive/outline` 变体 |
## 使用方式
```tsx
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
```
## 添加新组件
使用 shadcn/ui CLI 添加新组件:
```bash
npx shadcn-ui@latest add <component-name>
```
组件配置在项目根目录的 `components.json` 中定义。
+44
View File
@@ -0,0 +1,44 @@
# config/
应用级配置目录,存放功能特性的注册中心。
## 文件说明
| 文件 | 用途 |
| -------------- | ---------------------------------------- |
| `features.tsx` | 核心配置文件,定义所有工具功能的注册信息 |
## features.tsx
`FEATURES` 数组是路由和功能元数据的**单一事实来源**,每个功能定义包含:
- `key`:页面类型标识(`PageType`
- `labelKey` / `descriptionKey`i18n 翻译键
- `themeColorKey`:主题色(`primary/success/warning/error/secondary/info`
- `icon`lucide-react 图标组件
- `defaultVisible`:默认是否可见
- `components`:三种渲染模式的懒加载组件(`popup``sidepanel``tab`
## 已注册功能(11 个)
| key | 图标 | 说明 |
| -------------------- | ----------------- | ----------------- |
| `dashboard` | — | 仪表盘首页 |
| `timestamp` | Clock | 时间戳转换工具 |
| `storageCleaner` | Database | 存储清理工具 |
| `qrCode` | QrCode | 二维码工具 |
| `textStatistics` | FileText | 文本统计工具 |
| `jwt` | Key | JWT 解析工具 |
| `jsonDiff` | GitCompareArrows | JSON 差异比较工具 |
| `base64Converter` | ArrowLeftRight | Base64 转换器 |
| `markdownToHtml` | Code | Markdown 转 HTML |
| `htmlToMarkdown` | File | HTML 转 Markdown |
| `rightClickRestorer` | MousePointerClick | 右键菜单恢复工具 |
## 导出函数
- `getFeatureByKey(key)` — 根据 key 获取功能配置
- `getDefaultVisibleFeatureKeys()` — 获取默认可见的功能 key 列表
- `getAllFeatureKeys()` — 获取所有功能 key 列表
- `getDefaultPageOrder()` — 获取默认页面排序(不含 dashboard)
- `getEntryPointType()` — 判断当前入口类型(popup/sidepanel/tab
+6 -5
View File
@@ -9,8 +9,8 @@ import {
describe('features', () => { describe('features', () => {
describe('FEATURES', () => { describe('FEATURES', () => {
it('should have 10 features defined', () => { it('should have 11 features defined', () => {
expect(FEATURES).toHaveLength(10); expect(FEATURES).toHaveLength(11);
}); });
it('should have all required properties for each feature', () => { it('should have all required properties for each feature', () => {
@@ -95,7 +95,7 @@ describe('features', () => {
describe('getAllFeatureKeys', () => { describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => { it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys(); const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(10); expect(allKeys).toHaveLength(11);
expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('storageCleaner');
@@ -106,6 +106,7 @@ describe('features', () => {
expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('markdownToHtml'); expect(allKeys).toContain('markdownToHtml');
expect(allKeys).toContain('htmlToMarkdown'); expect(allKeys).toContain('htmlToMarkdown');
expect(allKeys).toContain('rightClickRestorer');
}); });
}); });
@@ -122,9 +123,9 @@ describe('features', () => {
expect(pageOrder).toContain('qrCode'); expect(pageOrder).toContain('qrCode');
}); });
it('should have 9 items in page order', () => { it('should have 10 items in page order', () => {
const pageOrder = getDefaultPageOrder(); const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(9); expect(pageOrder).toHaveLength(10);
}); });
}); });
}); });
+15
View File
@@ -11,6 +11,7 @@ import {
ArrowLeftRight, ArrowLeftRight,
Code, Code,
File, File,
MousePointerClick,
} from 'lucide-react'; } from 'lucide-react';
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
@@ -26,6 +27,7 @@ const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml')); const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown')); const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
export interface FeatureConfig { export interface FeatureConfig {
key: PageType; key: PageType;
@@ -170,6 +172,19 @@ export const FEATURES: FeatureConfig[] = [
tab: HtmlToMarkdownPage, tab: HtmlToMarkdownPage,
}, },
}, },
{
key: 'rightClickRestorer',
labelKey: 'features:rightClickRestorer.title',
descriptionKey: 'features:rightClickRestorer.description',
themeColorKey: 'success',
icon: MousePointerClick,
defaultVisible: true,
components: {
popup: RightClickRestorerPage,
sidepanel: RightClickRestorerPage,
tab: RightClickRestorerPage,
},
},
]; ];
export function getFeatureByKey(key: PageType): FeatureConfig | undefined { export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
-56
View File
@@ -1,56 +0,0 @@
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
export const THEME_COLORS = {
primary: '#1976d2',
primaryDark: '#1565c0',
primaryLight: '#42a5f5',
success: '#2e7d32',
successDark: '#1b5e20',
successLight: '#4caf50',
warning: '#e65100',
warningDark: '#bf360c',
warningLight: '#ff9800',
error: '#c62828',
errorDark: '#b71c1c',
errorLight: '#f44336',
purple: '#6a1b9a',
purpleDark: '#4a148c',
purpleLight: '#9c27b0',
indigo: '#303f9f',
indigoDark: '#1a237e',
indigoLight: '#7986cb',
white: '#FFFFFF',
black: '#000000',
} as const;
export const STATUS_COLORS = {
success: THEME_COLORS.success,
warning: THEME_COLORS.warning,
error: THEME_COLORS.error,
info: THEME_COLORS.primary,
} as const;
export const timestampPageStyles = {
primaryColor: THEME_COLORS.primary,
};
export const storageCleanerPageStyles = {
warningColor: THEME_COLORS.warning,
};
export const qrCodePageStyles = {
primaryColor: THEME_COLORS.success,
};
export const textStatisticsPageStyles = {
primaryColor: THEME_COLORS.purple,
};
export const base64ConverterPageStyles = {
primaryColor: THEME_COLORS.indigo,
};
@@ -1,135 +0,0 @@
# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档
- 日期:2026-05-13
- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离
## 背景与目标
当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx``entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。
问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。
目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。
## 设计概要
新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer``Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。
## 组件设计
### `components/PageErrorBoundary.tsx`(新增)
复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异:
- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构:
- 图标 (`ErrorOutlineIcon`)
- 标题:"该页面加载失败"
- 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。"
- 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动)
- 主操作按钮:"重试"`RefreshIcon`)—— 重置内部 state,让子树重新挂载
- **`resetKey` prop**:可选;当 `resetKey``componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作
- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报
接口:
```ts
interface PageErrorBoundaryProps {
children: ReactNode;
resetKey?: string | number; // 变化时自动重置
}
```
### `components/ErrorBoundary.tsx`(不变)
保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。
## 集成点
### `components/RouterContainer.tsx`(修改)
`Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`
```tsx
<Suspense fallback={<Spinner />}>
<PageErrorBoundary resetKey={currentPage}>
{Component && <Component />}
</PageErrorBoundary>
</Suspense>
```
说明:
- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误
- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预
- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义
### `entrypoints/options/App.tsx`(修改)
将顶层 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`。options 是单页应用,统一使用页面级错误卡片即可。
### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变)
保留外层 `<ErrorBoundary>` 包裹 `<RouterContainer />`,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职:
- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域
- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底
## 数据流 / 错误处理
### 捕获路径
- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获
- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获
- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理)
### 恢复路径
- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染
- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`
- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具
### 日志
沿用 `console.error`;不引入 Sentry / 外部上报。
## 测试策略
### 新增 `components/__tests__/PageErrorBoundary.test.tsx`
1. 正常渲染:子组件正常渲染时,输出原始 children
2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息)
3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容
4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children
5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置)
6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见
### 新增 `components/__tests__/RouterContainer.test.tsx`
- 通过 mock `useRouter``FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在
- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染
### 不变
- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变)
### 风格
遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react``render` + `screen.getByText` 断言。
## 文件清单
**新增:**
- `components/PageErrorBoundary.tsx`
- `components/__tests__/PageErrorBoundary.test.tsx`
- `components/__tests__/RouterContainer.test.tsx`
**修改:**
- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}`
- `entrypoints/options/App.tsx` — 将 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`
**不变:**
- `components/ErrorBoundary.tsx`
- `components/__tests__/ErrorBoundary.test.tsx`
- `entrypoints/popup/App.tsx``entrypoints/sidepanel/App.tsx`
- `config/features.tsx`
## 验收标准
1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击
2. 在错误状态下切换到其他工具,新页面能正常加载与显示
3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载
4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误
5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile``npm run lint``npm run test` 均通过
+51
View File
@@ -0,0 +1,51 @@
# entrypoints/
WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各个上下文。
## 入口文件
| 文件 | 用途 |
| ------------------------------- | --------------------------------------------------------------------------------------------- |
| `background.ts` | Service Worker 入口:注册右键菜单、监听菜单点击、处理消息通信、管理侧边栏状态、注入主环境脚本 |
| `content.ts` | Content Script 入口:注入所有页面(`<all_urls>`),在 `document_end` 时初始化消息处理器 |
| `rightClickRestorer.content.ts` | 专用 Content Script:处理右键菜单恢复功能,注入浮动状态徽章 |
## 子目录
### popup/
Popup 弹窗页面(点击扩展图标弹出)。
| 文件 | 用途 |
| ------------ | ------------------------------------------------------------------------------ |
| `index.html` | HTML 入口 |
| `main.tsx` | React 挂载点 |
| `App.tsx` | 根组件,组装 `RouterProvider` + `TopBar` + `ErrorBoundary` + `RouterContainer` |
### sidepanel/
侧边栏页面,结构与 popup 类似,额外通知 background 侧边栏开启/关闭状态。
### options/
设置页面,支持:
- 拖拽排序功能顺序(`@dnd-kit`
- 功能可见性管理(显示/隐藏)
- Popup/Sidepanel/Tab 三种模式独立配置
### content/
Content Script 内部分模块:
| 文件 | 用途 |
| ----------------------- | ------------------------------------------------------------------- |
| `messageHandler.ts` | 消息处理器初始化入口 |
| `contextMenuHandler.ts` | 右键菜单点击事件处理,执行时间戳转换/文本统计并通过 UI Popover 展示 |
| `uiPopover.ts` | 在页面中注入浮层 Popover UI,展示右键菜单操作结果 |
## 架构说明
- `background.ts` 是扩展的核心协调者,处理跨上下文通信
- `content.ts` 注入到所有页面,负责接收和处理来自 background 的消息
- `popup/``sidepanel/``options/` 共享同一套页面组件(来自 `pages/`),通过 `RouterProvider` 的不同配置实现独立路由
+118 -31
View File
@@ -5,12 +5,12 @@ import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMen
import { saveContextMenuData } from '@/utils/useContextMenuData'; import { saveContextMenuData } from '@/utils/useContextMenuData';
export default defineBackground(() => { export default defineBackground(() => {
// 1. 扩展初次安装或更新时,注册右键上下文大闸 // 1. 扩展初次安装或更新时,注册右键上下文菜单
browser.runtime.onInstalled.addListener(() => { browser.runtime.onInstalled.addListener(() => {
createAllContextMenus(); createAllContextMenus();
}); });
// 2. 右键点击中央中枢路由 // 2. 右键点击路由
browser.contextMenus.onClicked.addListener(async (info, _tab) => { browser.contextMenus.onClicked.addListener(async (info, _tab) => {
const result = parseContextMenuClick(info.menuItemId as string, info); const result = parseContextMenuClick(info.menuItemId as string, info);
@@ -24,12 +24,10 @@ export default defineBackground(() => {
const { featureKey, payload } = result.data; const { featureKey, payload } = result.data;
try { try {
// 检查侧边栏(Side Panel)的挂载激活状态
const sidePanelState = await browser.storage.local.get('sidePanelOpen'); const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true; const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) { if (isSidePanelOpen) {
// 如果侧边栏正开着,利用高性能管道直发
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload }); await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return; return;
} }
@@ -37,18 +35,13 @@ export default defineBackground(() => {
console.debug('[Context Menu] Side panel pipeline is not available:', err); console.debug('[Context Menu] Side panel pipeline is not available:', err);
} }
// 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取
await saveContextMenuData({ featureKey, payload }); await saveContextMenuData({ featureKey, payload });
// 打开 popup 弹窗
try { try {
await browser.action.openPopup(); await browser.action.openPopup();
} catch (err) { } catch (err) {
// 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁!
// 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标,
// 数据依旧完好如初,完美契合了你的设计注释!
console.warn( console.warn(
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留在内存中:', '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err, err,
); );
} }
@@ -65,7 +58,96 @@ export default defineBackground(() => {
} }
}); });
// 💡 3. 异步刷新请求监听 // 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
const sender = message.sender as chrome.runtime.MessageSender | undefined;
const tabId = sender?.tab?.id;
if (!tabId) {
console.warn('[RightClickRestorer] Injection request missing tabId');
return { success: false, message: 'Missing tabId' };
}
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'];
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
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,
});
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
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,
});
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
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,
});
},
world: 'MAIN',
});
return { success: true };
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[RightClickRestorer] executeScript failed:', errorMsg);
return { success: false, message: errorMsg };
}
});
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
onMessage(MessageAction.RELOAD_TAB, async (message) => { onMessage(MessageAction.RELOAD_TAB, async (message) => {
const { tabId, delay = 0 } = message.data; const { tabId, delay = 0 } = message.data;
@@ -75,28 +157,33 @@ export default defineBackground(() => {
}); });
}; };
// 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御 if (delay <= 0) {
// 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动
if (delay > 0 && delay < 1000) {
setTimeout(executeReload, delay);
} else if (delay >= 1000) {
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
// 创建一个临时的一次性 Alarm 闹钟
await browser.alarms.create(alarmName, { when: Date.now() + delay });
// 动态注册一个一次性的生命周期续航守卫
const alarmListener = (alarm: { name: string }) => {
if (alarm.name === alarmName) {
executeReload();
browser.alarms.onAlarm.removeListener(alarmListener);
}
};
browser.alarms.onAlarm.addListener(alarmListener);
} else {
executeReload(); executeReload();
return { success: true };
} }
return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' }; const alarmName = `reload-tab-${tabId}-${Date.now()}`;
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
await browser.alarms.create(alarmName, { when: Date.now() + delay });
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
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 };
}); });
}); });
+1 -6
View File
@@ -3,7 +3,6 @@ import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics'; import { getTextStats } from '@/utils/textStatistics';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文
function getI18nText(key: string, fallback: string): string { function getI18nText(key: string, fallback: string): string {
if (typeof chrome !== 'undefined' && chrome.i18n) { if (typeof chrome !== 'undefined' && chrome.i18n) {
return chrome.i18n.getMessage(key) || fallback; return chrome.i18n.getMessage(key) || fallback;
@@ -39,20 +38,16 @@ function convertTimestamp(input: string): string {
let lastClickX = 0; let lastClickX = 0;
let lastClickY = 0; let lastClickY = 0;
// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标
document.addEventListener( document.addEventListener(
'contextmenu', 'contextmenu',
(e) => { (e) => {
lastClickX = e.clientX; lastClickX = e.clientX;
lastClickY = e.clientY; lastClickY = e.clientY;
}, },
{ capture: true, passive: true }, // 优化滚动与捕获性能 { capture: true, passive: true },
); );
export function initContextMenuHandler(): void { export function initContextMenuHandler(): void {
// 💡 2. 全局自净化大闸(Global Auto-Purge Grid):
// 当用户在网页上进行左键点击、滚动视视口、或调整大小时,
// 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣!
const dismissPopover = (): void => { const dismissPopover = (): void => {
hidePopover(); hidePopover();
}; };
+1 -8
View File
@@ -1,5 +1,5 @@
import { SyntheticEvent, useEffect, useMemo, useState } from 'react'; import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
import { GripVertical, RefreshCw, Settings } from 'lucide-react'; import { GripVertical, RefreshCw } from 'lucide-react';
import { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -28,7 +28,6 @@ import {
} from '@/config/features'; } from '@/config/features';
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageHeader from '@/components/PageHeader';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const PALETTE_COLORS: Record<PaletteColorKey, string> = { const PALETTE_COLORS: Record<PaletteColorKey, string> = {
@@ -317,12 +316,6 @@ export default function App() {
{/* 顶部标题与导航栏 */} {/* 顶部标题与导航栏 */}
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8"> <div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<PageHeader
icon={<Settings size={20} />}
iconColor="#1976d2"
title="应用设置"
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
/>
{/* Tab 与恢复按钮同行 */} {/* Tab 与恢复按钮同行 */}
<div className="flex items-end justify-between border-b-0"> <div className="flex items-end justify-between border-b-0">
<div className="flex-1 flex"> <div className="flex-1 flex">
@@ -21,6 +21,7 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => key, t: (key: string) => key,
}), }),
withTranslation: () => (Component: any) => Component,
})); }));
describe('Options App', () => { describe('Options App', () => {
+194
View File
@@ -0,0 +1,194 @@
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> {
try {
const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT);
if (!response.success) {
console.error('[RightClickRestorer] Background injection failed:', response.message);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to request main world injection:', err);
}
}
/* ======================== Isolated World 事件拦截 ======================== */
function installEventIntercepts(): void {
window.addEventListener(
'contextmenu',
(e) => {
if (!isRestored) return;
e.stopPropagation();
},
true,
);
// 部分网站在 mousedown 阶段阻止 contextmenu
window.addEventListener(
'mousedown',
(e) => {
if (!isRestored) return;
if (e.button === 2 || (e.buttons & 2) !== 0) {
e.stopPropagation();
}
},
true,
);
}
/* ======================== 遮罩层穿透 ======================== */
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> {
if (isRestored) return;
await injectMainWorldScript();
// 再次检查,防止并发调用导致重复注册
if (isRestored) return;
installEventIntercepts();
initMousePenetration();
isRestored = true;
updateBadge();
}
/* ======================== 消息通信 ======================== */
function initMessaging(): void {
onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => {
await activateProtection();
return { success: true, restored: isRestored };
});
onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => {
return { success: true, restored: isRestored };
});
}
/* ======================== 入口 ======================== */
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
} else {
createBadge();
}
initMessaging();
},
});
+8
View File
@@ -60,6 +60,12 @@ export default tseslint.config(
'react-hooks': reactHooks, 'react-hooks': reactHooks,
}, },
settings: {
react: {
version: 'detect',
},
},
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸 // 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
rules: { rules: {
// 激活 react-hooks 官方推荐规则 // 激活 react-hooks 官方推荐规则
@@ -68,6 +74,8 @@ export default tseslint.config(
...reactPlugin.configs.recommended.rules, ...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules, ...reactPlugin.configs['jsx-runtime'].rules,
'react/prop-types': 'off',
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管 // 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
'no-unused-vars': 'off', 'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
+61
View File
@@ -0,0 +1,61 @@
# i18n/
国际化资源目录,管理多语言翻译和 i18next 初始化配置。
## 目录结构
```
i18n/
├── index.ts # i18next 初始化配置
└── locales/
├── zh/ # 中文翻译(默认语言)
│ ├── common.json # 通用文案
│ ├── features.json # 功能模块标题和描述
│ ├── timestamp.json # 时间戳工具翻译
│ ├── storageCleaner.json # 存储清理工具翻译
│ ├── qrCode.json # 二维码工具翻译
│ ├── textStatistics.json # 文本统计工具翻译
│ ├── jwt.json # JWT 工具翻译
│ ├── jsonDiff.json # JSON 差异工具翻译
│ ├── jsonFormat.json # JSON 格式化工具翻译
│ ├── base64Converter.json
│ ├── markdownToHtml.json
│ ├── htmlToMarkdown.json
│ └── rightClickRestorer.json
└── en/ # 英文翻译(结构同上)
└── ...
```
## index.ts
i18next 初始化配置:
- 同步加载 `common``features` 核心命名空间
- 自定义 `chromeStorage` 语言检测器,从 Chrome Storage 读取语言偏好
- `normalizeLanguage()` 将任意语言标识归一化为 `zh``en`
- 语言变更时同步更新 Day.js 本地化和 localStorage 快照
## 翻译键格式
- 命名空间:`common`(默认)、`features`、各功能独立命名空间
- 键格式:`namespace:key`(如 `features:timestamp.title``timestamp:unitMs`
## 使用方式
```tsx
// 页面组件 — 懒加载翻译
import { useLazyTranslation } from '@/utils/useLazyTranslation';
const { t } = useLazyTranslation('timestamp');
t('timestamp:title');
// 全局组件 — 直接使用
import { useTranslation } from 'react-i18next';
const { t } = useTranslation(['common', 'features']);
t('common:settings');
```
## 添加新翻译
1.`locales/{zh,en}/features.json` 添加功能标题和描述
2. 创建 `locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间
+16 -1
View File
@@ -24,7 +24,22 @@
}, },
"messages": { "messages": {
"copySuccess": "Copied to clipboard", "copySuccess": "Copied to clipboard",
"copyError": "Copy failed" "copyError": "Copy failed",
"copyEmpty": "Nothing to copy"
},
"errorBoundary": {
"title": "Oops, something went wrong",
"description": "The app encountered an unexpected error. You can try refreshing the page or resetting the app.",
"refresh": "Refresh App",
"retry": "Retry"
},
"pageErrorBoundary": {
"title": "This feature has encountered an error",
"description": "An internal script error occurred while loading or rendering this page. You can try again or switch to another tool from the navigation menu."
},
"router": {
"notFound": "Page Not Found",
"notFoundDescription": "This feature is not available or has been removed in the current runtime ({{entryPointType}})."
}, },
"textInputArea": { "textInputArea": {
"clear": "Clear", "clear": "Clear",
+4
View File
@@ -37,5 +37,9 @@
"htmlToMarkdown": { "htmlToMarkdown": {
"title": "HTML to Markdown", "title": "HTML to Markdown",
"description": "Real-time HTML conversion and Markdown preview" "description": "Real-time HTML conversion and Markdown preview"
},
"rightClickRestorer": {
"title": "Right Click Restorer",
"description": "Detect and restore disabled browser right-click menus"
} }
} }
+1
View File
@@ -15,6 +15,7 @@
"diffCount": "{{count}} differences", "diffCount": "{{count}} differences",
"invalidJson": "Invalid JSON format", "invalidJson": "Invalid JSON format",
"emptyHint": "Enter JSON on both sides and click Compare", "emptyHint": "Enter JSON on both sides and click Compare",
"fixErrorHint": "Fix the JSON syntax errors above to enable live comparison",
"added": "Added", "added": "Added",
"removed": "Removed", "removed": "Removed",
"modified": "Modified" "modified": "Modified"
+1
View File
@@ -14,6 +14,7 @@
"noContent": "Nothing to copy", "noContent": "Nothing to copy",
"invalidJson": "Invalid JSON format", "invalidJson": "Invalid JSON format",
"emptyHint": "Enter JSON and click Format", "emptyHint": "Enter JSON and click Format",
"fixErrorHint": "Fix the JSON syntax errors above to enable live formatting",
"originalSize": "Original size", "originalSize": "Original size",
"formattedSize": "Formatted size", "formattedSize": "Formatted size",
"diffMode": "Diff", "diffMode": "Diff",
+13
View File
@@ -0,0 +1,13 @@
{
"title": "Right Click Restorer",
"description": "Detect and restore disabled browser right-click menus",
"loading": "Loading...",
"currentDomain": "Current Domain",
"statusLocked": "Locked",
"statusUnlocked": "Unlocked",
"unsupported": "Unsupported",
"unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.",
"unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.",
"unlockBtn": "Unlock Right Click",
"alreadyUnlocked": "Right Click Unlocked"
}
+1
View File
@@ -2,6 +2,7 @@
"pageTitle": "Storage Cleaner", "pageTitle": "Storage Cleaner",
"pageSubtitle": "Clear cache, cookies, and local storage", "pageSubtitle": "Clear cache, cookies, and local storage",
"loading": "Loading...", "loading": "Loading...",
"initializing": "Reading site data...",
"occupied": "Occupied {{size}}", "occupied": "Occupied {{size}}",
"cleaning": "Cleaning...", "cleaning": "Cleaning...",
"cleanNow": "Clean Now", "cleanNow": "Clean Now",
+16 -1
View File
@@ -24,7 +24,22 @@
}, },
"messages": { "messages": {
"copySuccess": "已复制到剪贴板", "copySuccess": "已复制到剪贴板",
"copyError": "复制失败" "copyError": "复制失败",
"copyEmpty": "无内容可复制"
},
"errorBoundary": {
"title": "糟糕,出了点问题",
"description": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"refresh": "刷新应用",
"retry": "重新尝试"
},
"pageErrorBoundary": {
"title": "该功能运行异常",
"description": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
},
"router": {
"notFound": "页面未找到",
"notFoundDescription": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。"
}, },
"textInputArea": { "textInputArea": {
"clear": "清空", "clear": "清空",
+4
View File
@@ -37,5 +37,9 @@
"htmlToMarkdown": { "htmlToMarkdown": {
"title": "HTML 转 Markdown", "title": "HTML 转 Markdown",
"description": "实时 HTML 转换与 Markdown 预览" "description": "实时 HTML 转换与 Markdown 预览"
},
"rightClickRestorer": {
"title": "右键恢复",
"description": "检测并恢复被网站禁用的浏览器右键菜单"
} }
} }
+1
View File
@@ -15,6 +15,7 @@
"diffCount": "{{count}} 处差异", "diffCount": "{{count}} 处差异",
"invalidJson": "无效的 JSON 格式", "invalidJson": "无效的 JSON 格式",
"emptyHint": "输入两侧 JSON 后点击比较", "emptyHint": "输入两侧 JSON 后点击比较",
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式比对",
"added": "新增", "added": "新增",
"removed": "删除", "removed": "删除",
"modified": "修改" "modified": "修改"
+1
View File
@@ -14,6 +14,7 @@
"noContent": "无内容可复制", "noContent": "无内容可复制",
"invalidJson": "无效的 JSON 格式", "invalidJson": "无效的 JSON 格式",
"emptyHint": "输入 JSON 后点击格式化", "emptyHint": "输入 JSON 后点击格式化",
"fixErrorHint": "请修正上方 JSON 的语法错误以开启实时流式格式化",
"originalSize": "原始大小", "originalSize": "原始大小",
"formattedSize": "格式化后大小", "formattedSize": "格式化后大小",
"diffMode": "差异比较", "diffMode": "差异比较",
+13
View File
@@ -0,0 +1,13 @@
{
"title": "右键恢复",
"description": "检测并恢复被网站禁用的浏览器右键菜单",
"loading": "正在加载...",
"currentDomain": "当前域名",
"statusLocked": "未解锁",
"statusUnlocked": "已解锁",
"unsupported": "不支持",
"unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
"unlockBtn": "解锁当前网站右键",
"alreadyUnlocked": "右键已解锁"
}
+1
View File
@@ -2,6 +2,7 @@
"pageTitle": "存储清理", "pageTitle": "存储清理",
"pageSubtitle": "清理缓存、Cookies 及本地存储", "pageSubtitle": "清理缓存、Cookies 及本地存储",
"loading": "加载中...", "loading": "加载中...",
"initializing": "正在读取站点数据...",
"occupied": "已占用 {{size}}", "occupied": "已占用 {{size}}",
"cleaning": "正在清理...", "cleaning": "正在清理...",
"cleanNow": "立即清理", "cleanNow": "立即清理",
+26
View File
@@ -0,0 +1,26 @@
# lib/
通用库工具目录,存放与业务无关的底层工具函数。
## 文件说明
| 文件 | 用途 |
| ---------- | ------------------------------------ |
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
## cn()
组合 `clsx` + `tailwind-merge`,用于合并和去重 Tailwind CSS 类名:
```typescript
import { cn } from '@/lib/utils';
// 条件类名 + 合并外部 className
<div className={cn(
'flex items-center gap-3 px-3',
isActive && 'bg-primary text-primary-foreground',
className,
)}>
```
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
+144 -54
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useTranslation } from 'react-i18next'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper'; import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -19,7 +19,7 @@ interface Base64ConverterSectionProps {
} }
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) { export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useTranslation('base64Converter'); const { t } = useLazyTranslation('base64Converter');
const [direction, setDirection] = useStorageState( const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`, `base64Converter/${mode}Mode/direction`,
@@ -57,7 +57,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
}; };
return ( return (
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300"> <div className="w-full flex flex-col space-y-4">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup <SwitchButtonGroup
value={direction} value={direction}
@@ -86,7 +86,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
}} }}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={cn( className={cn(
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300', 'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer',
isDragging isDragging
? 'border-primary bg-primary/10' ? 'border-primary bg-primary/10'
: info : info
@@ -107,7 +107,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{isLoading ? ( {isLoading ? (
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" /> <div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
) : info ? ( ) : info ? (
<div className="flex flex-col items-center gap-1.5 text-center w-full animate-in fade-in duration-200"> <div className="flex flex-col items-center gap-1.5 text-center w-full">
{mode === 'image' && result && ( {mode === 'image' && result && (
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden"> <div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
<img <img
@@ -117,9 +117,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
/> />
</div> </div>
)} )}
<Upload <Upload className={cn('w-8 h-8 text-primary')} />
className={cn('w-8 h-8 text-primary', mode === 'file' && 'animate-bounce')}
/>
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate"> <span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name} {info.name}
</span> </span>
-220
View File
@@ -1,220 +0,0 @@
import { Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
import { useTranslation } from 'react-i18next';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
import { downloadBlob, formatFileSize, MAX_FILE_SIZE } from '@/utils/base64Converter';
import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConvertDirection } from '@/types/storage';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useBase64Converter } from './useBase64Converter'; // 💡 斩断重复代码
import { cn } from '@/lib/utils';
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
val === 'encode' || val === 'decode';
export default function FileMode() {
const { t } = useTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
'base64Converter/fileMode/direction',
'encode',
isValidDirection,
);
const {
result,
info,
isLoading,
isDragging,
setIsDragging,
fileInputRef,
encodeError,
decodeInput,
setDecodeInput,
decoded,
decodeError,
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
} = useBase64Converter({ mode: 'file' });
const handleDownload = () => {
if (decoded) downloadBlob(decoded.blob, decodedFileName);
};
return (
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: t('encode') },
{ value: 'decode', label: t('decode') },
]}
onChange={(next) => {
if (next && next !== direction) {
resetAll();
setDirection(next);
}
}}
size="small"
/>
</div>
{direction === 'encode' ? (
<div className="flex flex-col space-y-4">
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) safeFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300',
isDragging
? 'border-primary bg-primary/10'
: info
? 'border-primary/60 bg-primary/5'
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
)}
>
<input
ref={fileInputRef}
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) safeFileSelect(file);
}}
/>
{isLoading ? (
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
) : info ? (
<div className="flex flex-col items-center gap-1.5 text-center">
<Upload className="w-8 h-8 text-primary animate-bounce" />
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name}
</span>
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
{formatFileSize(info.size)} · {info.type}
</span>
<span className="text-[11px] font-medium text-primary/80 mt-1">
{t('clickOrDropToReplace')}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-center">
<Upload className="w-8 h-8 text-muted-foreground/60" />
<span className="text-xs font-bold text-foreground/80">
{t('clickOrDropToFile')}
</span>
<span className="text-[10px] font-medium text-muted-foreground/60">
{t('maxFileSize', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` })}
</span>
</div>
)}
</div>
{encodeError && (
<div
role="alert"
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive"
>
{encodeError}
</div>
)}
{result && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('base64Output')}
</span>
<div className="flex gap-2">
<CopyButton
text={result.rawBase64}
tooltip={t('copyRaw')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
<CopyButton
text={result.output}
tooltip={t('copyDataUri')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
</div>
<TextInputArea
readOnly
value={
result.output.length > 2000
? `${result.output.substring(0, 2000)}...`
: result.output
}
showClear={false}
minRows={4}
/>
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums">
<span>
{t('originalSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{t('encodedSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.outputBytes)}
</span>
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={resetAll}
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
>
<Trash2 className="w-3.5 h-3.5" />
{t('clear')}
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col space-y-4">
<TextInputArea
placeholder={t('decodeBase64Placeholder')}
value={decodeInput}
onChange={setDecodeInput}
externalError={decodeError || undefined}
showClear={true}
allowCopy={true}
minRows={6}
onClear={resetAll}
/>
{decoded && (
<DecodeResultPaper
title={t('decodedFileOutput')}
mimeType={decoded.mimeType}
blobSize={decoded.blob.size}
fileName={decodedFileName}
onFileNameChange={setCustomFileName}
onDownload={handleDownload}
/>
)}
</div>
)}
</div>
);
}
-259
View File
@@ -1,259 +0,0 @@
import { Image as ImageIcon, Trash2 } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
import { useTranslation } from 'react-i18next';
import CopyButton from '@/components/CopyButton';
import DecodeResultPaper from '@/components/DecodeResultPaper';
import { Button } from '@/components/ui/button';
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConvertDirection } from '@/types/storage';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useBase64Converter } from './useBase64Converter'; // 💡 引入共享核心
import { cn } from '@/lib/utils';
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
val === 'encode' || val === 'decode';
export default function ImageMode() {
const { t } = useTranslation('base64Converter');
const [direction, setDirection] = useStorageState(
'base64Converter/imageMode/direction',
'encode',
isValidDirection,
);
// 消费完全托管的核心 Hook,消灭本地多余状态机
const {
result,
info,
isLoading,
isDragging,
setIsDragging,
fileInputRef,
encodeError,
decodeInput,
setDecodeInput,
decoded,
decodeError,
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
} = useBase64Converter({ mode: 'image' });
const handleDirectionChange = (next: Base64ConvertDirection) => {
if (!next || next === direction) return;
resetAll();
setDirection(next);
};
const handleDownload = () => {
if (decoded) downloadBlob(decoded.blob, decodedFileName);
};
return (
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: t('encode') },
{ value: 'decode', label: t('decode') },
]}
onChange={handleDirectionChange}
size="small"
/>
</div>
{direction === 'encode' ? (
<div className="flex flex-col space-y-4">
{/* 图片拖拽投递箱终端 */}
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) safeFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer transition-all duration-300',
isDragging
? 'border-primary bg-primary/10'
: info
? 'border-primary/60 bg-primary/5'
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
)}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) safeFileSelect(file);
}}
/>
{isLoading ? (
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
) : info ? (
<div className="flex flex-col items-center gap-1.5 text-center animate-in fade-in duration-200 w-full">
{result && (
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
<img
src={result.output}
alt="preview"
className="max-h-32 w-full object-contain rounded"
/>
</div>
)}
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name}
</span>
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
{formatFileSize(info.size)} · {info.type}
</span>
<span className="text-[11px] font-medium text-primary/80 mt-1">
{t('clickOrDropToReplace')}
</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5 text-center">
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
<span className="text-xs font-bold text-foreground/80">
{t('clickOrDropToImage')}
</span>
<span className="text-[10px] font-medium text-muted-foreground/60">
{t('supportedFormats')}
</span>
</div>
)}
</div>
{encodeError && (
<div
role="alert"
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive tracking-wide"
>
{encodeError}
</div>
)}
{result && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{t('base64Output')}
</span>
<div className="flex gap-2">
<CopyButton
text={result.rawBase64}
tooltip={t('copyRaw')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
<CopyButton
text={result.output}
tooltip={t('copyDataUri')}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
</div>
<TextInputArea
readOnly
value={
result.output.length > 2000
? `${result.output.substring(0, 2000)}...`
: result.output
}
showClear={false}
minRows={4}
/>
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums">
<span>
{t('originalSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{t('encodedSize')}:{' '}
<span className="font-semibold text-foreground/80">
{formatFileSize(result.outputBytes)}
</span>
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={resetAll}
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
>
<Trash2 className="w-3.5 h-3.5" />
{t('clear')}
</Button>
</div>
</div>
)}
{info && !result && (
<div className="flex justify-end select-none">
<Button
variant="outline"
size="sm"
onClick={resetAll}
className="h-8 rounded-md text-xs gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10"
>
<Trash2 className="w-3.5 h-3.5" />
{t('clear')}
</Button>
</div>
)}
</div>
) : (
<div className="flex flex-col space-y-4">
<TextInputArea
placeholder={t('decodeBase64Placeholder')}
value={decodeInput}
onChange={setDecodeInput}
externalError={decodeError || undefined}
showClear={true}
allowCopy={true}
minRows={6}
onClear={resetAll}
/>
{decoded && (
<div className="animate-in slide-in-from-bottom-2 duration-300">
<DecodeResultPaper
title={t('decodedImageOutput')}
mimeType={decoded.mimeType}
blobSize={decoded.blob.size}
fileName={decodedFileName}
onFileNameChange={setCustomFileName}
onDownload={handleDownload}
>
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
<img
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
alt="decoded preview"
className="max-h-40 w-full rounded-lg object-contain bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[size:10px_10px] bg-[position:0_0,0_5px,5px_-5px,-5px_0] dark:bg-none"
/>
</div>
</DecodeResultPaper>
</div>
)}
</div>
)}
</div>
);
}
+6 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { useTranslation } from 'react-i18next'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter'; import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
@@ -20,7 +20,7 @@ interface TextModeProps {
} }
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { t } = useTranslation('base64Converter'); const { t } = useLazyTranslation('base64Converter');
// 1. 纯净的核心源状态机:只保留输入源和转换方向 // 1. 纯净的核心源状态机:只保留输入源和转换方向
const [input, setInput] = useState(''); const [input, setInput] = useState('');
@@ -93,7 +93,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
}; };
return ( return (
<div className="w-full flex flex-col space-y-4 animate-in fade-in duration-300"> <div className="w-full flex flex-col space-y-4">
{/* 受控方向切流中枢 */} {/* 受控方向切流中枢 */}
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup <SwitchButtonGroup
@@ -125,7 +125,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
- 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。 - 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。
*/} */}
{showImageHint && ( {showImageHint && (
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20 animate-in slide-in-from-top-1 duration-200"> <div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
<span className="text-xs font-semibold text-primary tracking-tight"> <span className="text-xs font-semibold text-primary tracking-tight">
{t('imageDataUriHint')} {t('imageDataUriHint')}
</span> </span>
@@ -134,7 +134,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onSwitchToImageMode} onClick={onSwitchToImageMode}
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 transition-colors px-2.5" className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
> >
{t('switchToImageMode')} {t('switchToImageMode')}
</Button> </Button>
@@ -143,7 +143,7 @@ export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
{/* 5. 编码/解码核心数据承载流卡片 */} {/* 5. 编码/解码核心数据承载流卡片 */}
{output && ( {output && (
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3 animate-in slide-in-from-bottom-2 duration-300"> <div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none"> <div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{outputLabel} {outputLabel}
@@ -1,233 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import FileMode from '../FileMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
<button data-testid="copy-button" data-tooltip={tooltip}>
{text.slice(0, 20)}
</button>
),
}));
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
// useStorageState's async loadState may overwrite user toggle if we click before the
// initial chrome.storage read settles. Flush pending microtasks first.
const waitForStorageReady = () => act(() => Promise.resolve());
describe('FileMode', () => {
it('应该渲染文件上传区域', async () => {
render(<FileMode />);
await waitForStorageReady();
expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
expect(screen.getByText('maxFileSize')).toBeInTheDocument();
});
it('应该处理有效的文件选择', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
// 文件输入是隐藏的,直接触发 change 事件
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
expect(screen.getByText('base64Output')).toBeInTheDocument();
});
});
it('应该拒绝超出大小限制的文件', async () => {
render(<FileMode />);
await waitForStorageReady();
// 创建一个超过 10MB 的文件
const largeContent = new Uint8Array(11 * 1024 * 1024);
const file = new File([largeContent], 'large.bin', { type: 'application/octet-stream' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
});
});
it('点击清除按钮应该清空文件状态', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('clear'));
await waitFor(() => {
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
});
});
it('应该显示文件大小和类型信息', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.txt')).toBeInTheDocument();
});
// 文件类型显示在 caption 中,格式为 "size · type"
expect(screen.getByText(/test\.txt/)).toBeInTheDocument();
});
it('应该显示原始大小和编码大小', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test content'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText(/originalSize/)).toBeInTheDocument();
expect(screen.getByText(/encodedSize/)).toBeInTheDocument();
});
});
it('应该提供复制按钮', async () => {
render(<FileMode />);
await waitForStorageReady();
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
const copyButtons = screen.getAllByTestId('copy-button');
expect(copyButtons.length).toBeGreaterThanOrEqual(2);
});
});
it('应该渲染 encode/decode 切换按钮', () => {
render(<FileMode />);
expect(screen.getByText('encode')).toBeInTheDocument();
expect(screen.getByText('decode')).toBeInTheDocument();
});
it('切到 decode 应该显示 Base64 输入框', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
expect(await screen.findByPlaceholderText('decodeBase64Placeholder')).toBeInTheDocument();
});
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
});
});
it('解码后的文件名应该可编辑', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
const filenameInput = (await screen.findByDisplayValue('decoded.pdf')) as HTMLInputElement;
fireEvent.change(filenameInput, { target: { value: 'my-report.pdf' } });
expect(filenameInput.value).toBe('my-report.pdf');
});
it('解码后应该显示下载按钮', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
expect(await screen.findByText('download')).toBeInTheDocument();
});
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: '!!!not base64' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
});
});
it('切换方向时应该清空解码状态', async () => {
render(<FileMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('encode'));
await waitFor(() => {
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
});
});
});
@@ -1,182 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import ImageMode from '../ImageMode';
// Mock CopyButton
vi.mock('@/components/CopyButton', () => ({
default: ({ text, tooltip }: { text: string; tooltip?: string }) => (
<button data-testid="copy-button" data-tooltip={tooltip}>
{text.slice(0, 20)}
</button>
),
}));
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
const waitForStorageReady = () => act(() => Promise.resolve());
describe('ImageMode', () => {
it('应该渲染图像上传区域', async () => {
render(<ImageMode />);
await waitForStorageReady();
expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
expect(screen.getByText('supportedFormats')).toBeInTheDocument();
});
it('应该接受有效的图像文件', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['fake-image-data'], 'test.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText('base64Output')).toBeInTheDocument();
});
});
it('应该拒绝非图像文件', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['not an image'], 'test.txt', { type: 'text/plain' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('unsupportedImageType');
});
});
it('应该拒绝超出大小限制的图像', async () => {
render(<ImageMode />);
await waitForStorageReady();
const largeContent = new Uint8Array(11 * 1024 * 1024);
const file = new File([largeContent], 'large.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
});
});
it('应该通过扩展名识别图像', async () => {
render(<ImageMode />);
await waitForStorageReady();
// 没有 MIME 类型但有正确扩展名
const file = new File(['fake'], 'test.jpg');
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.jpg')).toBeInTheDocument();
});
});
it('点击清除按钮应该清空图像状态', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['fake'], 'test.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
expect(screen.getByText('test.png')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('clear'));
await waitFor(() => {
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
});
});
it('应该显示图像预览', async () => {
render(<ImageMode />);
await waitForStorageReady();
const file = new File(['fake-image'], 'test.png', { type: 'image/png' });
const hiddenInput = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(hiddenInput, { target: { files: [file] } });
await waitFor(() => {
const img = screen.getByAltText('preview');
expect(img).toBeInTheDocument();
expect(img.tagName.toLowerCase()).toBe('img');
});
});
it('应该渲染 encode/decode 切换按钮', async () => {
render(<ImageMode />);
await waitForStorageReady();
expect(screen.getByText('encode')).toBeInTheDocument();
expect(screen.getByText('decode')).toBeInTheDocument();
});
it('解码 PNG Base64 后应该显示图像预览', async () => {
render(<ImageMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('decodedImageOutput')).toBeInTheDocument();
const img = screen.getByAltText('decoded preview');
expect(img).toBeInTheDocument();
expect(img.tagName.toLowerCase()).toBe('img');
});
});
it('解码后默认文件名应该为 decoded.png', async () => {
render(<ImageMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
vi.advanceTimersByTime(250);
});
expect(await screen.findByDisplayValue('decoded.png')).toBeInTheDocument();
});
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
render(<ImageMode />);
await waitForStorageReady();
fireEvent.click(screen.getByText('decode'));
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
fireEvent.change(input, { target: { value: '!!!not base64' } });
act(() => {
vi.advanceTimersByTime(250);
});
await waitFor(() => {
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
});
});
});
@@ -4,6 +4,7 @@ import TextMode from '../TextMode';
// Mock CopyButton // Mock CopyButton
vi.mock('@/components/CopyButton', () => ({ vi.mock('@/components/CopyButton', () => ({
CopyButton: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>, default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
})); }));
@@ -18,18 +19,18 @@ describe('TextMode', () => {
it('应该渲染编码/解码切换按钮', () => { it('应该渲染编码/解码切换按钮', () => {
render(<TextMode />); render(<TextMode />);
expect(screen.getByText('encode')).toBeInTheDocument(); expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
expect(screen.getByText('decode')).toBeInTheDocument(); expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
}); });
it('应该渲染输入框和转换按钮', () => { it('应该渲染输入框和转换按钮', () => {
render(<TextMode />); render(<TextMode />);
expect(screen.getByPlaceholderText('textInputPlaceholder')).toBeInTheDocument(); expect(screen.getByPlaceholderText('base64Converter:textInputPlaceholder')).toBeInTheDocument();
}); });
it('应该将文本编码为 Base64', async () => { it('应该将文本编码为 Base64', async () => {
render(<TextMode />); render(<TextMode />);
const input = screen.getByPlaceholderText('textInputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
act(() => { act(() => {
@@ -37,18 +38,18 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('base64Output')).toBeInTheDocument(); expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
}); });
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
}); });
it('应该解码 Base64 文本', async () => { it('应该解码 Base64 文本', async () => {
render(<TextMode />); render(<TextMode />);
// 切换到解码模式 // 切换到解码模式
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64InputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
act(() => { act(() => {
@@ -56,18 +57,18 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('textOutput')).toBeInTheDocument(); expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument();
}); });
expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello'); expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument();
}); });
it('应该对无效 Base64 显示错误', async () => { it('应该对无效 Base64 显示错误', async () => {
render(<TextMode />); render(<TextMode />);
// 切换到解码模式 // 切换到解码模式
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64InputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'invalid!!!' } }); fireEvent.change(input, { target: { value: 'invalid!!!' } });
act(() => { act(() => {
@@ -75,7 +76,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('invalidBase64')).toBeInTheDocument(); expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
}); });
}); });
@@ -83,7 +84,7 @@ describe('TextMode', () => {
render(<TextMode />); render(<TextMode />);
// 先编码 // 先编码
const input = screen.getByPlaceholderText('textInputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
act(() => { act(() => {
@@ -91,22 +92,22 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
}); });
// 切换方向 // 切换方向
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
// 输出应该被清除 // 输出应该被清除
await waitFor(() => { await waitFor(() => {
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
}); });
}); });
it('点击清除按钮应该清空所有内容', async () => { it('点击清除按钮应该清空所有内容', async () => {
render(<TextMode />); render(<TextMode />);
const input = screen.getByPlaceholderText('textInputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
fireEvent.change(input, { target: { value: 'Hello' } }); fireEvent.change(input, { target: { value: 'Hello' } });
act(() => { act(() => {
@@ -114,13 +115,13 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' })); fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
await waitFor(() => { await waitFor(() => {
expect(screen.queryByTestId('copy-button')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
expect(input).toHaveValue(''); expect(input).toHaveValue('');
}); });
}); });
@@ -128,48 +129,48 @@ describe('TextMode', () => {
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => { it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
render(<TextMode />); render(<TextMode />);
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64InputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
}); });
expect(screen.getByText('imageDataUriHint')).toBeInTheDocument(); expect(screen.getByText('base64Converter:imageDataUriHint')).toBeInTheDocument();
expect(screen.getByText('switchToImageMode')).toBeInTheDocument(); expect(screen.getByText('base64Converter:switchToImageMode')).toBeInTheDocument();
}); });
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => { it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
render(<TextMode />); render(<TextMode />);
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64InputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
expect(screen.queryByText('imageDataUriHint')).not.toBeInTheDocument(); expect(screen.queryByText('base64Converter:imageDataUriHint')).not.toBeInTheDocument();
}); });
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => { it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
const onSwitch = vi.fn(); const onSwitch = vi.fn();
render(<TextMode onSwitchToImageMode={onSwitch} />); render(<TextMode onSwitchToImageMode={onSwitch} />);
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64InputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
}); });
fireEvent.click(screen.getByText('switchToImageMode')); fireEvent.click(screen.getByText('base64Converter:switchToImageMode'));
expect(onSwitch).toHaveBeenCalledTimes(1); expect(onSwitch).toHaveBeenCalledTimes(1);
}); });
it('解码模式下对二进制数据应该显示更清晰的错误', async () => { it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
render(<TextMode />); render(<TextMode />);
fireEvent.click(screen.getByText('decode')); fireEvent.click(screen.getByText('base64Converter:decode'));
const input = screen.getByPlaceholderText('base64InputPlaceholder'); const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => { act(() => {
@@ -177,7 +178,7 @@ describe('TextMode', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('binaryDataDetected')).toBeInTheDocument(); expect(screen.getByText('base64Converter:binaryDataDetected')).toBeInTheDocument();
}); });
}); });
}); });
@@ -73,11 +73,4 @@ describe('Base64ConverterPage', () => {
}); });
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument(); expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
}); });
it('应该渲染页面标题', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument();
expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument();
});
}); });
+1 -18
View File
@@ -1,7 +1,4 @@
import { Image as ImageIcon, Type, Upload } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader';
import { base64ConverterPageStyles } from '@/config/pageTheme';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConverterPageMode } from '@/types/storage'; import type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './TextMode'; import TextMode from './TextMode';
@@ -22,22 +19,8 @@ export default function Index() {
isValidPageMode, isValidPageMode,
); );
const modeIcon: Record<PageMode, React.ReactNode> = {
text: <Type className="h-4 w-4" />,
file: <Upload className="h-4 w-4" />,
image: <ImageIcon className="h-4 w-4" />,
};
return ( return (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none animate-in fade-in duration-300"> <div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none">
<PageHeader
title={t('base64Converter:pageTitle')}
subtitle={t('base64Converter:pageSubtitle')}
icon={modeIcon[pageMode]}
iconColor={base64ConverterPageStyles.primaryColor}
className="pb-1"
/>
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
+2 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import type { FileToBase64Result } from '@/utils/base64Converter'; import type { FileToBase64Result } from '@/utils/base64Converter';
import { import {
base64ToBlob, base64ToBlob,
@@ -21,7 +21,7 @@ interface UseBase64ConverterProps {
} }
export function useBase64Converter({ mode }: UseBase64ConverterProps) { export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const { t } = useTranslation('base64Converter'); const { t } = useLazyTranslation('base64Converter');
const [result, setResult] = useState<FileToBase64Result | null>(null); const [result, setResult] = useState<FileToBase64Result | null>(null);
const [info, setInfo] = useState<FileInfo | null>(null); const [info, setInfo] = useState<FileInfo | null>(null);
+3 -4
View File
@@ -10,7 +10,7 @@ const PALETTE_COLORS: Record<PaletteColorKey, string> = {
success: '22, 163, 74', // green success: '22, 163, 74', // green
warning: '217, 119, 6', // amber (存储清理的橙色轴) warning: '217, 119, 6', // amber (存储清理的橙色轴)
error: '220, 38, 38', // red error: '220, 38, 38', // red
secondary: '147, 51, 2 purple', secondary: '147, 51, 232',
info: '37, 99, 235', // blue info: '37, 99, 235', // blue
}; };
@@ -46,7 +46,6 @@ export default function ToolCard({
*/ */
className={cn( className={cn(
'group relative rounded-xl border border-border/70 bg-card text-card-foreground p-4 h-auto flex flex-col items-stretch justify-start gap-3 shadow-sm select-none box-border', 'group relative rounded-xl border border-border/70 bg-card text-card-foreground p-4 h-auto flex flex-col items-stretch justify-start gap-3 shadow-sm select-none box-border',
'transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
'hover:bg-muted/30', 'hover:bg-muted/30',
'hover:border-[rgba(var(--tool-color),0.45)]', 'hover:border-[rgba(var(--tool-color),0.45)]',
'hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]', 'hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)] dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]',
@@ -60,7 +59,7 @@ export default function ToolCard({
{/* 左侧圆形图标容器 */} {/* 左侧圆形图标容器 */}
<div <div
className={cn( className={cn(
'flex items-center justify-center w-10 h-10 rounded-xl shrink-0 transition-colors duration-300', 'flex items-center justify-center w-10 h-10 rounded-xl shrink-0',
'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]', 'bg-[rgba(var(--tool-color),0.08)] dark:bg-[rgba(var(--tool-color),0.12)]',
'text-[rgb(var(--tool-color))]', 'text-[rgb(var(--tool-color))]',
)} )}
@@ -82,7 +81,7 @@ export default function ToolCard({
</div> </div>
{/* 右侧指示小箭头 */} {/* 右侧指示小箭头 */}
<div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 transition-all duration-300 ease-in-out group-hover:translate-x-0.5"> <div className="text-muted-foreground/40 group-hover:text-[rgb(var(--tool-color))] p-1 shrink-0 group-hover:translate-x-0.5">
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</div> </div>
+1 -1
View File
@@ -16,7 +16,7 @@ export default function DashboardPage() {
<div <div
className={cn( className={cn(
'grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto', 'grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(290px,1fr))] auto-rows-auto gap-3.5 p-3.5 w-full h-auto',
'animate-in fade-in duration-300 select-none', 'select-none',
)} )}
> >
{pageOrder.map((key) => { {pageOrder.map((key) => {
+6 -13
View File
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { Code, Download, Trash2 } from 'lucide-react'; import { Download, Trash2 } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button
@@ -50,13 +49,7 @@ export default function HtmlToMarkdownPage() {
- 彻底清除多余的 container max-w-7xl 这种网页大边距, - 彻底清除多余的 container max-w-7xl 这种网页大边距,
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。 - 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
*/ */
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300"> <div className="p-4 w-full flex flex-col space-y-4 select-none">
<PageHeader
title={t('pageTitle')}
subtitle={t('pageSubtitle')}
icon={<Code className="h-4 w-4" />}
/>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* 工具栏集成区 */} {/* 工具栏集成区 */}
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12"> <div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
@@ -79,7 +72,7 @@ export default function HtmlToMarkdownPage() {
size="sm" size="sm"
onClick={handleDownload} onClick={handleDownload}
disabled={!result.markdown} disabled={!result.markdown}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 transition-all" className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
{t('download')} {t('download')}
@@ -90,7 +83,7 @@ export default function HtmlToMarkdownPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleClear} onClick={handleClear}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60 transition-all" className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{t('clear')} {t('clear')}
@@ -102,7 +95,7 @@ export default function HtmlToMarkdownPage() {
- 💡 核心修复点:将硬编码的 bg-red-50 实色,完美超进化为系统的全自适应透明色变体 - 💡 核心修复点:将硬编码的 bg-red-50 实色,完美超进化为系统的全自适应透明色变体
*/} */}
{error && ( {error && (
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide animate-in shake duration-300"> <div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide">
{error} {error}
</div> </div>
)} )}
@@ -121,7 +114,7 @@ export default function HtmlToMarkdownPage() {
- 只要用户用鼠标点击了内部的 textarea,外层整块精巧的圆角大边框会一帧内亮起 primary 系统的深色呼吸发光环, - 只要用户用鼠标点击了内部的 textarea,外层整块精巧的圆角大边框会一帧内亮起 primary 系统的深色呼吸发光环,
- 这种“全外包裹层框聚焦”的体验极大模仿了本地原生 IDE 的硬核专业体验! - 这种“全外包裹层框聚焦”的体验极大模仿了本地原生 IDE 的硬核专业体验!
*/ */
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col transition-all duration-200 focus-within:ring-1 focus-within:ring-ring focus-within:border-ring"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
{/* 卡片头部:改用标准的灰色 bg-muted/50 */} {/* 卡片头部:改用标准的灰色 bg-muted/50 */}
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none"> <div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
+5 -5
View File
@@ -30,7 +30,7 @@ export default function DiffNavigator({
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-center gap-3 px-4 py-2 rounded-lg border border-border bg-muted/30 select-none animate-in fade-in duration-200', 'flex items-center justify-center gap-3 px-4 py-2 rounded-lg border border-border bg-muted/30 select-none',
className, className,
)} )}
{...props} {...props}
@@ -60,9 +60,9 @@ export default function DiffNavigator({
aria-label={t('jsonDiff:previousDiff')} aria-label={t('jsonDiff:previousDiff')}
onClick={onPrev} onClick={onPrev}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground transition-all hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'disabled:pointer-events-none disabled:opacity-30 active:scale-95', // 4. 边界拦截:触顶时优雅淡化并锁死点击 'disabled:pointer-events-none disabled:opacity-30', // 4. 边界拦截:触顶时优雅淡化并锁死点击
)} )}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -81,9 +81,9 @@ export default function DiffNavigator({
aria-label={t('jsonDiff:nextDiff')} aria-label={t('jsonDiff:nextDiff')}
onClick={onNext} onClick={onNext}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground transition-all hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'disabled:pointer-events-none disabled:opacity-30 active:scale-95', // 4. 边界拦截:触底时优雅淡化并锁死点击 'disabled:pointer-events-none disabled:opacity-30', // 4. 边界拦截:触底时优雅淡化并锁死点击
)} )}
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
+1 -1
View File
@@ -197,7 +197,7 @@ const UnifiedRow = ({ depth, type, text, active, multiline }: UnifiedRowProps) =
return ( return (
<div <div
className={cn( className={cn(
'flex items-start w-full font-mono py-0.5 select-text group transition-colors', 'flex items-start w-full font-mono py-0.5 select-text group',
currentTheme.bg, currentTheme.bg,
currentTheme.text, currentTheme.text,
// 4. 高亮定位条:不再使用生硬的蓝圆环,改为现代编辑器的“侧边左高亮带”设计,质感直接拉满 // 4. 高亮定位条:不再使用生硬的蓝圆环,改为现代编辑器的“侧边左高亮带”设计,质感直接拉满
+3 -6
View File
@@ -74,10 +74,7 @@ export default function JsonConvertSection({
: null; : null;
return ( return (
<div <div className={cn('w-full flex flex-col gap-4', className)} {...props}>
className={cn('w-full flex flex-col gap-4 animate-in fade-in duration-300', className)}
{...props}
>
{/* 输入区 */} {/* 输入区 */}
<TextInputArea <TextInputArea
placeholder={t(`jsonFormat:${pk}InputPlaceholder`)} placeholder={t(`jsonFormat:${pk}InputPlaceholder`)}
@@ -93,7 +90,7 @@ export default function JsonConvertSection({
{/* 4. 结果展示或状态引导卡片区 */} {/* 4. 结果展示或状态引导卡片区 */}
{result && result.output ? ( {result && result.output ? (
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden animate-in slide-in-from-bottom-2 duration-300"> <div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
{/* 结果栏精致头部 */} {/* 结果栏精致头部 */}
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
@@ -136,7 +133,7 @@ export default function JsonConvertSection({
/* 5. 空状态提示容器:完美的中性虚线引导,不喧宾夺主 */ /* 5. 空状态提示容器:完美的中性虚线引导,不喧宾夺主 */
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? '请修正上方 JSON 的语法错误以激活流式转换' : t(`jsonFormat:${pk}EmptyHint`)} {error ? t('jsonFormat:fixErrorHint') : t(`jsonFormat:${pk}EmptyHint`)}
</p> </p>
</div> </div>
)} )}
+4 -4
View File
@@ -62,7 +62,7 @@ export default function JsonFormatSection() {
: null; : null;
return ( return (
<div className="w-full flex flex-col gap-4 animate-in fade-in duration-300"> <div className="w-full flex flex-col gap-4">
{/* 工具控制栏 */} {/* 工具控制栏 */}
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60"> <div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
<div className="flex gap-4 items-center w-full"> <div className="flex gap-4 items-center w-full">
@@ -95,7 +95,7 @@ export default function JsonFormatSection() {
/> />
<Label <Label
htmlFor="sort-keys-checkbox" htmlFor="sort-keys-checkbox"
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground transition-colors" className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground"
> >
{t('jsonFormat:sortKeys')} {t('jsonFormat:sortKeys')}
</Label> </Label>
@@ -118,7 +118,7 @@ export default function JsonFormatSection() {
{/* 格式化结果流面板展示 */} {/* 格式化结果流面板展示 */}
{result && result.formatted ? ( {result && result.formatted ? (
<div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden animate-in slide-in-from-bottom-2 duration-300"> <div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
{/* 结果栏头部 */} {/* 结果栏头部 */}
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
@@ -160,7 +160,7 @@ export default function JsonFormatSection() {
/* 空状态指示引导区 */ /* 空状态指示引导区 */
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? '请修正上方 JSON 语法错误以开启实时流式格式化' : t('jsonFormat:emptyHint')} {error ? t('jsonFormat:fixErrorHint') : t('jsonFormat:emptyHint')}
</p> </p>
</div> </div>
)} )}
+2 -2
View File
@@ -135,7 +135,7 @@ const NodeRow = React.memo(
<div <div
onClick={() => setOverride(expanded ? 'closed' : 'open')} onClick={() => setOverride(expanded ? 'closed' : 'open')}
className={cn( className={cn(
'group flex items-center gap-1 py-0.5 pr-2 text-xs font-mono select-none cursor-pointer rounded-sm transition-colors w-full h-[22px] leading-relaxed', 'group flex items-center gap-1 py-0.5 pr-2 text-xs font-mono select-none cursor-pointer rounded-sm w-full h-[22px] leading-relaxed',
theme.bg, theme.bg,
isActive && isActive &&
'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0', 'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0',
@@ -207,7 +207,7 @@ const NodeRow = React.memo(
<div <div
ref={rowRef} ref={rowRef}
className={cn( className={cn(
'flex items-center gap-1 py-0.5 pr-2 font-mono text-xs w-full h-[22px] leading-relaxed rounded-sm transition-colors', 'flex items-center gap-1 py-0.5 pr-2 font-mono text-xs w-full h-[22px] leading-relaxed rounded-sm',
theme.bg, theme.bg,
isActive && isActive &&
'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0', 'bg-primary/10 relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-blue-500 rounded-none ring-0',
+4 -32
View File
@@ -1,7 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRightLeft, Braces, GitCompareArrows, Minimize2 } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader';
import JsonDiffInput from './JsonDiffInput'; import JsonDiffInput from './JsonDiffInput';
import DiffResult from './DiffResult'; import DiffResult from './DiffResult';
import DiffNavigator from './DiffNavigator'; import DiffNavigator from './DiffNavigator';
@@ -99,22 +97,6 @@ export default function Index() {
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
const modeTitles: Record<PageMode, { title: string; subtitle: string }> = {
diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' },
format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' },
yaml: { title: 'jsonFormat:yamlTitle', subtitle: 'jsonFormat:yamlSubtitle' },
toml: { title: 'jsonFormat:tomlTitle', subtitle: 'jsonFormat:tomlSubtitle' },
minify: { title: 'jsonFormat:minifyTitle', subtitle: 'jsonFormat:minifySubtitle' },
};
const modeIcon: Record<PageMode, React.ReactNode> = {
diff: <GitCompareArrows className="h-4 w-4" />,
format: <Braces className="h-4 w-4" />,
yaml: <ArrowRightLeft className="h-4 w-4" />,
toml: <ArrowRightLeft className="h-4 w-4" />,
minify: <Minimize2 className="h-4 w-4" />,
};
const yamlConvert: ConvertFunction = useCallback((text: string) => { const yamlConvert: ConvertFunction = useCallback((text: string) => {
const r = jsonToYaml(text); const r = jsonToYaml(text);
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
@@ -131,15 +113,7 @@ export default function Index() {
}, []); }, []);
return ( return (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300"> <div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
<PageHeader
title={t(modeTitles[pageMode].title)}
subtitle={t(modeTitles[pageMode].subtitle)}
icon={modeIcon[pageMode]}
iconColor="#3b82f6"
className="pb-1"
/>
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
onChange={(v: PageMode) => setPageMode(v)} onChange={(v: PageMode) => setPageMode(v)}
@@ -155,7 +129,7 @@ export default function Index() {
/> />
{pageMode === 'diff' ? ( {pageMode === 'diff' ? (
<div className="flex flex-col space-y-4 animate-in fade-in duration-200"> <div className="flex flex-col space-y-4">
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60"> <div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
<SwitchButtonGroup <SwitchButtonGroup
value={viewMode} value={viewMode}
@@ -208,9 +182,7 @@ export default function Index() {
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]"> <div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed"> <p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
{leftError || rightError {leftError || rightError ? t('jsonDiff:fixErrorHint') : t('jsonDiff:emptyHint')}
? '请修正上方 JSON 的语法错误以开启实时流式比对'
: t('jsonDiff:emptyHint')}
</p> </p>
</div> </div>
)} )}
+4 -14
View File
@@ -1,6 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Key } from 'lucide-react';
import PageHeader from '@/components/PageHeader';
import { parseJwt, stringifyJson } from '@/utils/jwt'; import { parseJwt, stringifyJson } from '@/utils/jwt';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
@@ -19,9 +17,7 @@ interface SectionProps {
const Section = ({ title, content, colorClass, bgClass, borderClass }: SectionProps) => { const Section = ({ title, content, colorClass, bgClass, borderClass }: SectionProps) => {
const { t } = useLazyTranslation('jwt'); const { t } = useLazyTranslation('jwt');
return ( return (
<div <div className={cn('p-4 rounded-xl border border-solid', bgClass, borderClass)}>
className={cn('p-4 rounded-xl border border-solid transition-colors', bgClass, borderClass)}
>
<div className="flex justify-between items-center mb-2 select-none"> <div className="flex justify-between items-center mb-2 select-none">
<span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}> <span className={cn('text-xs font-bold tracking-wider uppercase', colorClass)}>
{title} {title}
@@ -72,13 +68,7 @@ export default function Index() {
}, [debouncedInput]); }, [debouncedInput]);
return ( return (
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300 select-none"> <div className="p-4 w-full flex flex-col space-y-4 select-none">
<PageHeader
title={t('jwt:pageTitle')}
subtitle={t('jwt:pageSubtitle')}
icon={<Key className="h-4 w-4" />} // 💡 规范锁死 Icon 宽高,抹杀闪烁
/>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* 输入终端 */} {/* 输入终端 */}
<TextInputArea <TextInputArea
@@ -98,7 +88,7 @@ export default function Index() {
{/* 解码看板结果展现 */} {/* 解码看板结果展现 */}
{result && !result.error && ( {result && !result.error && (
<div className="flex flex-col gap-4 animate-in slide-in-from-bottom-2 duration-300"> <div className="flex flex-col gap-4">
{/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */} {/* Header 分区:完美致敬 JWT.io 的鲜艳色彩,同时实现黑夜暗化自适应 */}
<Section <Section
title={t('jwt:headerTitle')} title={t('jwt:headerTitle')}
@@ -118,7 +108,7 @@ export default function Index() {
/> />
{/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */} {/* Signature 签名区:完全对齐标准的 shadcn 骨架阶度 */}
<div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm transition-colors"> <div className="p-4 rounded-xl border border-border bg-secondary/40 shadow-sm">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase"> <span className="text-xs font-bold tracking-wider text-muted-foreground/90 uppercase">
{t('jwt:signatureTitle')} {t('jwt:signatureTitle')}
+11 -17
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Download, Printer, Trash2 } from 'lucide-react'; import { Download, Printer, Trash2 } from 'lucide-react';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -38,7 +37,7 @@ const PREVIEW_STYLES = `
.markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; } .markdown-body h1 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.6em; }
.markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; } .markdown-body h2 { border-bottom: 1px solid var(--md-border); padding-bottom: 0.3em; font-size: 1.35em; }
.markdown-body p { margin-top: 0; margin-bottom: 16px; } .markdown-body p { margin-top: 0; margin-bottom: 16px; }
.markdown-body a { color: #3b82f6; text-decoration: none; } .markdown-body a { color: var(--md-link-color); text-decoration: none; }
.markdown-body a:hover { text-decoration: underline; } .markdown-body a:hover { text-decoration: underline; }
.markdown-body code { .markdown-body code {
background-color: var(--md-code-bg); background-color: var(--md-code-bg);
@@ -111,6 +110,7 @@ export default function MarkdownToHtmlPage() {
--md-pre-bg: rgba(255,255,255,0.04); --md-pre-bg: rgba(255,255,255,0.04);
--md-muted: #8b949e; --md-muted: #8b949e;
--md-quote-line: rgba(255,255,255,0.25); --md-quote-line: rgba(255,255,255,0.25);
--md-link-color: #58a6ff;
}` }`
: `:root { : `:root {
--md-bg: #ffffff; --md-bg: #ffffff;
@@ -120,6 +120,7 @@ export default function MarkdownToHtmlPage() {
--md-pre-bg: rgba(128,128,128,0.03); --md-pre-bg: rgba(128,128,128,0.03);
--md-muted: #4b5563; --md-muted: #4b5563;
--md-quote-line: rgba(128,128,128,0.3); --md-quote-line: rgba(128,128,128,0.3);
--md-link-color: #3b82f6;
}`; }`;
// 💡 3. 核心大清洗:将全局基础树(html, body)与派生样式完全独立硬编码,杜绝任何语法踩踏 // 💡 3. 核心大清洗:将全局基础树(html, body)与派生样式完全独立硬编码,杜绝任何语法踩踏
@@ -176,14 +177,7 @@ export default function MarkdownToHtmlPage() {
const showPreview = previewMode !== 'html'; const showPreview = previewMode !== 'html';
return ( return (
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300"> <div className="p-4 w-full flex flex-col space-y-4 select-none">
<PageHeader
title={t('pageTitle')}
subtitle={t('pageSubtitle')}
icon={<Code className="h-4 w-4" />}
className="pb-1"
/>
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{/* 工具集成控制中枢 */} {/* 工具集成控制中枢 */}
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12"> <div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
@@ -204,7 +198,7 @@ export default function MarkdownToHtmlPage() {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleClear} onClick={handleClear}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60 transition-all" className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm text-destructive hover:text-destructive hover:bg-destructive/5 dark:hover:bg-destructive/10 border-input/60"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
{t('clear')} {t('clear')}
@@ -214,7 +208,7 @@ export default function MarkdownToHtmlPage() {
size="sm" size="sm"
onClick={handlePrint} onClick={handlePrint}
disabled={!result.html} disabled={!result.html}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 transition-all" className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
> >
<Printer className="h-3.5 w-3.5" /> <Printer className="h-3.5 w-3.5" />
{t('print')} {t('print')}
@@ -224,7 +218,7 @@ export default function MarkdownToHtmlPage() {
size="sm" size="sm"
onClick={handleDownload} onClick={handleDownload}
disabled={!result.html} disabled={!result.html}
className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm active:scale-95 transition-all" className="h-8 rounded-md font-medium text-xs gap-1.5 shadow-sm"
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
{t('download')} {t('download')}
@@ -236,7 +230,7 @@ export default function MarkdownToHtmlPage() {
{error && ( {error && (
<div <div
role="alert" role="alert"
className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide animate-in shake duration-300" className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-destructive text-xs font-semibold tracking-wide"
> >
{error} {error}
</div> </div>
@@ -251,7 +245,7 @@ export default function MarkdownToHtmlPage() {
> >
{/* Markdown 输入翼终端 */} {/* Markdown 输入翼终端 */}
{showInput && ( {showInput && (
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col transition-all duration-200 focus-within:ring-1 focus-within:ring-ring focus-within:border-ring animate-in fade-in"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none"> <div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{t('inputLabel')} {t('inputLabel')}
@@ -271,7 +265,7 @@ export default function MarkdownToHtmlPage() {
{/* 实时 HTML/Iframe 预览翼终端 */} {/* 实时 HTML/Iframe 预览翼终端 */}
{showPreview && ( {showPreview && (
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col animate-in fade-in"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm overflow-hidden flex flex-col">
<div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none"> <div className="flex h-9 items-center justify-between px-4 bg-muted/50 border-b border-border select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')} {(previewMode as string) === 'html' ? t('htmlOutputLabel') : t('previewLabel')}
+16 -23
View File
@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePage from '../index'; import QrCodePage from '../index';
// 💡 1. 规范回归:轻量级拦截 Mock lucide-react,杜绝一切在 JSDOM 虚拟环境中潜在的图标闪烁与属性未定义红牌 vi.mock('lucide-react', async (importOriginal) => {
vi.mock('lucide-react', () => ({ const actual = await importOriginal<typeof import('lucide-react')>();
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>, return {
Type: () => <div>Type</div>, ...actual,
Upload: () => <div>Upload</div>, // 增量伪造需要高精嗅探的 QrCode 核心定位图标
Image: () => <div>Image</div>, QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
Trash2: () => <div>Trash2</div>, };
})); });
// Mock useLazyTranslation // Mock useLazyTranslation
vi.mock('@/utils/useLazyTranslation', () => ({ vi.mock('@/utils/useLazyTranslation', () => ({
@@ -27,7 +27,7 @@ vi.mock('@/components/GlobalSnackbar', () => ({
}), }),
})); }));
// Mock getEntryPointType // Mock getEntryPointType(保留原厂其他特征配置,仅模拟入口路由环境)
vi.mock('@/config/features', async (importOriginal) => { vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>(); const actual = await importOriginal<typeof import('@/config/features')>();
return { return {
@@ -36,7 +36,7 @@ vi.mock('@/config/features', async (importOriginal) => {
}; };
}); });
// Mock 子组件 // Mock 高频变化的子组件,收拢断言边界
vi.mock('@/components/QrCodePreview', () => ({ vi.mock('@/components/QrCodePreview', () => ({
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>, default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
})); }));
@@ -45,7 +45,7 @@ vi.mock('@/components/ImageUploader', () => ({
default: () => <div data-testid="image-uploader">ImageUploader</div>, default: () => <div data-testid="image-uploader">ImageUploader</div>,
})); }));
// Mock QRious // Mock QRious 动态图像离屏生成引擎
vi.mock('qrious', () => ({ vi.mock('qrious', () => ({
default: vi.fn().mockImplementation(() => ({ default: vi.fn().mockImplementation(() => ({
toDataURL: () => 'data:image/png;base64,mock', toDataURL: () => 'data:image/png;base64,mock',
@@ -53,6 +53,10 @@ vi.mock('qrious', () => ({
})); }));
describe('QrCodePage', () => { describe('QrCodePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('应该默认渲染生成模式', () => { it('应该默认渲染生成模式', () => {
render(<QrCodePage />); render(<QrCodePage />);
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument(); expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
@@ -64,12 +68,6 @@ describe('QrCodePage', () => {
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument(); expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
}); });
it('应该渲染页面标题', () => {
render(<QrCodePage />);
expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
});
it('切换到解析模式应该渲染 ImageUploader', () => { it('切换到解析模式应该渲染 ImageUploader', () => {
render(<QrCodePage />); render(<QrCodePage />);
fireEvent.click(screen.getByText('qrCode:qrToUrl')); fireEvent.click(screen.getByText('qrCode:qrToUrl'));
@@ -89,16 +87,11 @@ describe('QrCodePage', () => {
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => { it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => {
render(<QrCodePage />); render(<QrCodePage />);
// ✅ 完美对接:重构后不管是 Generate 还是 Parse 面板,
// 其外部包裹层里的标准独立 <Label /> 组件依然包含对应的语义文本,可以顺畅被 ByText 捕获!
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument(); expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
}); });
it('应该渲染双翼响应式卡片网格布局', () => { it('应该渲染双翼响应式卡片网格布局', () => {
const { container } = render(<QrCodePage />); const { container } = render(<QrCodePage />);
// 💡 修复点:废除极度脆弱的 '.grid.grid-cols-1.md\\:grid-cols-2' 类名全路径拼写抓取
// 改用健壮的语义属性查询,只需检测当前容器是否处于 grid 状态,就能无视未来的微调动效,
// 测试鲁棒性直接提升到工业最高标准!
const gridContainer = container.querySelector('.grid'); const gridContainer = container.querySelector('.grid');
expect(gridContainer).toBeInTheDocument(); expect(gridContainer).toBeInTheDocument();
}); });
+2 -2
View File
@@ -10,11 +10,11 @@ export default function GeneratePanel() {
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext(); const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5 animate-in fade-in duration-300"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
{/* 左翼:高性能受控输入翼终端 */} {/* 左翼:高性能受控输入翼终端 */}
<div <div
className={cn( className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4 transition-all duration-200', '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',
)} )}
> >
+2 -2
View File
@@ -61,7 +61,7 @@ export default function ParsePanel() {
/* 💡 统一大视觉轴: /* 💡 统一大视觉轴:
- 追加 p-0.5 微隔离,配合 gap-6 建立与生成面板(GeneratePanel)绝对像素对齐的网格天平。 - 追加 p-0.5 微隔离,配合 gap-6 建立与生成面板(GeneratePanel)绝对像素对齐的网格天平。
*/ */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5 animate-in fade-in duration-300"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full items-stretch select-none p-0.5">
{/* 左翼:图片接收/拖拽/剪贴板上传终端 */} {/* 左翼:图片接收/拖拽/剪贴板上传终端 */}
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<ImageUploader <ImageUploader
@@ -78,7 +78,7 @@ export default function ParsePanel() {
{/* 右翼:高阶解析出码只读终端 */} {/* 右翼:高阶解析出码只读终端 */}
<div <div
className={cn( className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4 transition-all duration-200', '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',
)} )}
+4 -15
View File
@@ -1,7 +1,5 @@
import { QrCode as QrCodeIcon } from 'lucide-react'; // 💡 别名规整,防止与页面组件发生重名误判
import PageHeader from '@/components/PageHeader';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { qrCodePageStyles } from '@/config/pageTheme';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { QrCodeContext } from './contexts/QrCodeContext'; import { QrCodeContext } from './contexts/QrCodeContext';
import { useQrCode } from './hooks/useQrCode'; import { useQrCode } from './hooks/useQrCode';
@@ -25,16 +23,7 @@ export default function Index() {
- 彻底剥离破坏流式宽度的 max-w-[400px] 枷锁,开启标准的 w-full 全自适应包裹。 - 彻底剥离破坏流式宽度的 max-w-[400px] 枷锁,开启标准的 w-full 全自适应包裹。
- 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排! - 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排!
*/} */}
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300"> <div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
{/* 标题控制栏:追加微调 py-0.5,防范文字边缘截断 */}
<PageHeader
title={t('qrCode:pageTitle')}
subtitle={t('qrCode:pageSubtitle')}
icon={<QrCodeIcon className="h-4 w-4" />} // 💡 规范对齐:强制锁死 Icon 宽高,抹杀闪烁
iconColor={qrCodePageStyles.primaryColor}
className="pb-1"
/>
{/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */} {/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
<div className="w-full sm:w-fit pt-0.5"> <div className="w-full sm:w-fit pt-0.5">
<SwitchButtonGroup <SwitchButtonGroup
@@ -51,11 +40,11 @@ export default function Index() {
*/} */}
<div className="w-full pt-1.5"> <div className="w-full pt-1.5">
{qrCode.mode === 'generate' ? ( {qrCode.mode === 'generate' ? (
<div className="animate-in fade-in duration-200"> <div>
<GeneratePanel /> <GeneratePanel />
</div> </div>
) : ( ) : (
<div className="animate-in fade-in duration-200"> <div>
<ParsePanel /> <ParsePanel />
</div> </div>
)} )}
+140
View File
@@ -0,0 +1,140 @@
# pages/
功能页面组件目录,每个子目录对应一个工具页面。
## 目录结构约定
典型页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
├── SubComponent.tsx # 子组件(可选)
└── __tests__/ # 测试文件
└── index.test.tsx
```
## 页面列表
### Dashboard/
仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。
| 文件 | 用途 |
| -------------- | -------------------------------------------- |
| `index.tsx` | 页面组件,渲染工具卡片网格 |
| `ToolCard.tsx` | 工具卡片组件,展示图标、标题、描述和实时数据 |
### Timestamp/
时间戳转换工具,支持秒/毫秒级互转、多时区选择、实时时钟。
| 文件 | 用途 |
| -------------------------- | ----------------------------------------------------------------- |
| `index.tsx` | 页面 UI |
| `useTimestampConverter.ts` | 业务逻辑 Hook,包含转换模式、输入、单位、时区状态和响应式计算管线 |
| `LiveClock.tsx` | 实时时钟子组件,`React.memo` 优化 |
| `ResultView.tsx` | 转换结果展示子组件 |
| `constants.ts` | 时区列表等常量 |
### StorageCleaner/
浏览器存储清理工具,支持 Cookie/LocalStorage/SessionStorage/IndexedDB/Cache/SW 清理。
| 文件 | 用途 |
| --------------------------- | -------------- |
| `index.tsx` | 页面 UI |
| `useStorageCleaner.ts` | 业务逻辑 Hook |
| `StorageOptionsGrid.tsx` | 清理选项网格 |
| `StorageCleanerConfirm.tsx` | 清理确认对话框 |
| `AutoRefreshToggle.tsx` | 自动刷新开关 |
| `ErrorDisplay.tsx` | 错误展示组件 |
| `CleaningResult.tsx` | 清理结果展示 |
| `OptionItem.tsx` | 单个选项组件 |
### QrCode/
二维码工具,支持生成(URL→QR)和解析(QR→URL)。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### TextStatistics/
文本统计工具,实时计算字符数、单词数、行数、字节大小。
| 文件 | 用途 |
| ----------- | --------------------------------------------- |
| `index.tsx` | 页面组件,集成 `TextInputArea` 和统计结果展示 |
### Jwt/
JWT 解析工具,解码 Header/Payload/Signature。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### JsonTools/
JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。
| 文件 | 用途 |
| ------------------------ | ---------------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `types.ts` | 类型定义 |
| `diffEngine.ts` | 差异比较引擎 |
| `JsonDiffInput.tsx` | JSON 输入组件 |
| `DiffResult.tsx` | 差异结果展示 |
| `DiffNavigator.tsx` | 差异导航器 |
| `JsonFormatSection.tsx` | 格式化区域 |
| `JsonConvertSection.tsx` | 转换区域(YAML/TOML/Minify |
| `JsonTree.tsx` | JSON 树形展示 |
### Base64Converter/
Base64 编解码工具,支持文本/文件/图片三种模式。
| 文件 | 用途 |
| ---------------------------- | --------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `useBase64Converter.ts` | 业务逻辑 Hook |
| `TextMode.tsx` | 文本模式 |
| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 |
### MarkdownToHtml/
Markdown 转 HTML 工具,支持分栏/预览/源码三种视图模式。
### HtmlToMarkdown/
HTML 转 Markdown 工具,支持分栏/预览/Markdown 三种视图模式。
### RightClickRestorer/
右键菜单恢复工具,解除网站对右键的限制。
| 文件 | 用途 |
| -------------------------- | ------------- |
| `index.tsx` | 页面 UI |
| `useRightClickRestorer.ts` | 业务逻辑 Hook |
## 新增页面
1.`types/storage.d.ts` 添加 `PageType` 联合类型成员
2.`config/features.tsx``FEATURES` 数组添加配置
3.`pages/` 创建页面目录(遵循上述结构)
4.`i18n/locales/{zh,en}/features.json` 添加翻译
5. 如需新权限,更新 `wxt.config.ts`
6. 添加对应的单元测试
@@ -0,0 +1,38 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import RightClickRestorerPage from '../index';
const mockUnlock = vi.fn();
vi.mock('../useRightClickRestorer', () => ({
useRightClickRestorer: () => ({
domain: 'example.com',
isLoading: false,
isUnlocked: false,
isUnsupported: false,
unlock: mockUnlock,
}),
}));
describe('RightClickRestorerPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render current domain', () => {
render(<RightClickRestorerPage />);
expect(screen.getByText(/example\.com/)).toBeInTheDocument();
});
it('should render locked status', () => {
render(<RightClickRestorerPage />);
expect(screen.getByText(/statusLocked/)).toBeInTheDocument();
});
it('should call unlock when button clicked', () => {
render(<RightClickRestorerPage />);
const button = screen.getByRole('button');
fireEvent.click(button);
expect(mockUnlock).toHaveBeenCalled();
});
});
@@ -0,0 +1,92 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { useRightClickRestorer } from '../useRightClickRestorer';
import { sendMessageToContent } from '@/utils/messages';
const mockTabsQuery = vi.fn();
vi.mock('@/utils/messages', () => ({
MessageAction: {
RESTORE_RIGHT_CLICK: 'restoreRightClick',
QUERY_RIGHT_CLICK_STATUS: 'queryRightClickStatus',
},
sendMessageToContent: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
chrome.tabs.query = mockTabsQuery;
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
});
describe('useRightClickRestorer', () => {
it('should load domain and query status', async () => {
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.domain).toBe('example.com');
expect(result.current.isUnlocked).toBe(false);
expect(result.current.isUnsupported).toBe(false);
expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus');
});
it('should mark internal pages as unsupported', async () => {
mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
chrome.tabs.query = mockTabsQuery;
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isUnsupported).toBe(true);
expect(result.current.isUnlocked).toBe(false);
expect(sendMessageToContent).not.toHaveBeenCalled();
});
it('should unlock right click', async () => {
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false });
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true });
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.unlock();
});
expect(result.current.isUnlocked).toBe(true);
expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick');
});
it('should not unlock unsupported pages', async () => {
mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
chrome.tabs.query = mockTabsQuery;
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.unlock();
});
expect(result.current.isUnlocked).toBe(false);
expect(sendMessageToContent).not.toHaveBeenCalled();
});
it('should handle sendMessage failure gracefully', async () => {
vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed'));
const { result } = renderHook(() => useRightClickRestorer());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => {
await result.current.unlock();
});
expect(result.current.isUnlocked).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react';
import { useRightClickRestorer } from './useRightClickRestorer';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function RightClickRestorerPage() {
const { t } = useLazyTranslation('rightClickRestorer');
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{t('rightClickRestorer:loading')}
</span>
</div>
);
}
return (
<div className="p-4 w-full flex flex-col space-y-4">
{/* Current Domain */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="p-4">
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label>
<div className="mt-2 flex items-center justify-between gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'}
</code>
{isUnsupported ? (
<Badge variant="destructive" className="gap-1 shrink-0">
<AlertTriangle className="h-3 w-3" />
{t('rightClickRestorer:unsupported')}
</Badge>
) : isUnlocked ? (
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0">
<ShieldCheck className="h-3 w-3" />
{t('rightClickRestorer:statusUnlocked')}
</Badge>
) : (
<Badge variant="secondary" className="gap-1 shrink-0">
<Shield className="h-3 w-3" />
{t('rightClickRestorer:statusLocked')}
</Badge>
)}
</div>
</div>
</div>
{/* Unlock Action */}
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
<div className="p-4 space-y-3">
{isUnsupported ? (
<>
<p className="text-xs text-muted-foreground">
{t('rightClickRestorer:unsupportedDesc')}
</p>
<Button className="w-full gap-2" disabled variant="secondary">
<AlertTriangle className="h-4 w-4" />
{t('rightClickRestorer:unsupported')}
</Button>
</>
) : (
<>
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
<Button
className="w-full gap-2"
onClick={() => void unlock()}
disabled={isUnlocked}
variant={isUnlocked ? 'secondary' : 'default'}
>
<MousePointerClick className="h-4 w-4" />
{isUnlocked
? t('rightClickRestorer:alreadyUnlocked')
: t('rightClickRestorer:unlockBtn')}
</Button>
</>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { useCallback, useEffect, useState } from 'react';
import { MessageAction, sendMessageToContent } from '@/utils/messages';
const UNSUPPORTED_PROTOCOLS = new Set([
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'brave:',
]);
function isUnsupportedPage(url: string | undefined): boolean {
if (!url) return true;
try {
const protocol = new URL(url).protocol;
return UNSUPPORTED_PROTOCOLS.has(protocol);
} catch {
return true;
}
}
export interface UseRightClickRestorerReturn {
domain: string;
isLoading: boolean;
isUnlocked: boolean;
isUnsupported: boolean;
unlock: () => Promise<void>;
}
export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
const [isUnsupported, setIsUnsupported] = useState<boolean>(false);
useEffect(() => {
const load = async () => {
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab?.url;
if (url) {
try {
setDomain(new URL(url).hostname);
} catch {
setDomain('');
}
}
if (isUnsupportedPage(url)) {
setIsUnsupported(true);
setIsLoading(false);
return;
}
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
if (response?.success) {
setIsUnlocked(response.restored);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to load state:', err);
} finally {
setIsLoading(false);
}
};
void load();
}, []);
const unlock = useCallback(async () => {
if (isUnsupported) return;
try {
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
if (response?.success) {
setIsUnlocked(response.restored);
}
} catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err);
}
}, [isUnsupported]);
return {
domain,
isLoading,
isUnlocked,
isUnsupported,
unlock,
};
}
+34 -19
View File
@@ -1,17 +1,44 @@
/**
* 自动刷新开关组件
*
* 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
* 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
*
* @example
* ```tsx
* <AutoRefreshToggle
* reloadAfterClean={reloadAfterClean}
* onChange={(checked) => setReloadAfterClean(checked)}
* />
* ```
*/
import React from 'react'; import React from 'react';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// 引入官方的 Switch 原子组件
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
interface AutoRefreshToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> { export interface AutoRefreshToggleProps extends Omit<
autoRefresh: boolean; React.HTMLAttributes<HTMLDivElement>,
'onChange'
> {
/** 开关的当前状态(受控) */
reloadAfterClean: boolean;
/** 状态变化时的回调函数 */
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
} }
/**
* 自动刷新开关
*
* @param reloadAfterClean - 当前开关状态
* @param onChange - 状态变化回调,接收新的布尔值
* @param className - 额外的 CSS 类名,用于覆盖或扩展样式
* @param props - 透传给外层 div 的其他 HTML 属性
*/
export default function AutoRefreshToggle({ export default function AutoRefreshToggle({
autoRefresh, reloadAfterClean,
onChange, onChange,
className, className,
...props ...props
@@ -21,15 +48,12 @@ export default function AutoRefreshToggle({
return ( return (
<div <div
className={cn( className={cn(
// 外层包裹维持优雅的 shadcn 风格中性卡片,去除硬编码 mb-3 扩展灵活性 'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm flex justify-between items-center',
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring flex justify-between items-center',
className, className,
)} )}
{...props} {...props}
> >
{/* 3. 使用标准的 shadcn/ui Label 组件: {/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
绑定 htmlFor 建立安全的表单无障碍桥梁,使得用户点击文字也能触发开关联动
*/}
<Label <Label
htmlFor="auto-refresh-switch" htmlFor="auto-refresh-switch"
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight" className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
@@ -37,16 +61,7 @@ export default function AutoRefreshToggle({
{t('storageCleaner:autoRefresh')} {t('storageCleaner:autoRefresh')}
</Label> </Label>
{/* 4. 超进化:彻底废除 200 个字符的原生 checkbox 拼接! <Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} />
完美调用 shadcn 的 Switch 组件。它会自动应用全站统一的主色(Primary)、
带阻尼的滑块硬件加速动效、以及教科书级别的 WAI-ARIA 无障碍键盘焦点提示。
*/}
<Switch
id="auto-refresh-switch"
checked={autoRefresh}
onCheckedChange={onChange}
className="data-[state=checked]:bg-primary" // 如果依然需要特定的琥珀色可写 data-[state=checked]:bg-amber-500
/>
</div> </div>
); );
} }
+1 -4
View File
@@ -17,10 +17,7 @@ export default function CleaningResult({ result, className, ...props }: Cleaning
const isSuccess = result.success; const isSuccess = result.success;
return ( return (
<div <div className={cn('w-full', className)} {...props}>
className={cn('animate-in fade-in slide-in-from-top-1 duration-200 w-full', className)}
{...props}
>
{/* 2. 彻底重构容器类名结构: {/* 2. 彻底重构容器类名结构:
- 成功状态:采用 Tailwind 官方推荐的 emerald 体系,利用 /10 (10% 透明度) 和 /20 (边框)。 - 成功状态:采用 Tailwind 官方推荐的 emerald 体系,利用 /10 (10% 透明度) 和 /20 (边框)。
- 失败状态:完全放权给标准的 border-destructive/20 和 bg-destructive/5。 - 失败状态:完全放权给标准的 border-destructive/20 和 bg-destructive/5。
+2 -2
View File
@@ -13,7 +13,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
// 1. 精简层级:单层外壳直接搞定居中、响应式高度与外部类名扩展 // 1. 精简层级:单层外壳直接搞定居中、响应式高度与外部类名扩展
<div <div
className={cn( className={cn(
'flex flex-col items-center justify-center py-8 min-h-[240px] sm:min-h-[360px] p-4 text-center animate-in fade-in zoom-in-95 duration-200', 'flex flex-col items-center justify-center py-8 min-h-[240px] sm:min-h-[360px] p-4 text-center',
className, className,
)} )}
{...props} {...props}
@@ -25,7 +25,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
*/} */}
<div className="w-full max-w-xs flex flex-col items-center justify-center rounded-xl p-5 border border-destructive/20 bg-destructive/5 shadow-sm"> <div className="w-full max-w-xs flex flex-col items-center justify-center rounded-xl p-5 border border-destructive/20 bg-destructive/5 shadow-sm">
{/* 3. 图标与主要错误信息全面对接 text-destructive 语义色 */} {/* 3. 图标与主要错误信息全面对接 text-destructive 语义色 */}
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 text-destructive mb-3.5 shrink-0 animate-bounce [animation-duration:2s]"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-destructive/10 text-destructive mb-3.5 shrink-0">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
</div> </div>
+2 -2
View File
@@ -29,7 +29,7 @@ export default function OptionItem({
// 3. 跨越级交互升级:将外部容器升级为一个高度敏感的可点击 Tab 热区 // 3. 跨越级交互升级:将外部容器升级为一个高度敏感的可点击 Tab 热区
onClick={onChange} onClick={onChange}
className={cn( className={cn(
'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none transition-all duration-200', 'flex justify-between items-center py-2.5 px-3.5 rounded-xl border cursor-pointer select-none',
// 4. 彻底抛弃硬编码黄底: // 4. 彻底抛弃硬编码黄底:
// - 选中时:使用 bg-primary/5 (系统主色超淡叠加) 配合标准 border-primary/30。 // - 选中时:使用 bg-primary/5 (系统主色超淡叠加) 配合标准 border-primary/30。
// - 未选中时:保持透明 border-transparent,悬停呈现 bg-muted。 // - 未选中时:保持透明 border-transparent,悬停呈现 bg-muted。
@@ -45,7 +45,7 @@ export default function OptionItem({
<div className="flex-1 min-w-0 mr-4"> <div className="flex-1 min-w-0 mr-4">
<span <span
className={cn( className={cn(
'block text-xs font-semibold leading-tight truncate transition-colors', 'block text-xs font-semibold leading-tight truncate',
checked ? 'text-foreground font-bold' : 'text-foreground/80', checked ? 'text-foreground font-bold' : 'text-foreground/80',
)} )}
> >
@@ -42,7 +42,6 @@ export function StorageCleanerConfirm({
<DialogContent <DialogContent
className={cn( className={cn(
'w-[90%] max-w-[340px] p-6 gap-0 rounded-2xl overflow-hidden shadow-xl border border-border bg-card text-card-foreground', 'w-[90%] max-w-[340px] p-6 gap-0 rounded-2xl overflow-hidden shadow-xl border border-border bg-card text-card-foreground',
'animate-in fade-in-50 zoom-in-95 duration-200',
)} )}
> >
{/* 头部标题区域 */} {/* 头部标题区域 */}
@@ -72,7 +71,7 @@ export function StorageCleanerConfirm({
</div> </div>
{/* 风险警告横幅 */} {/* 风险警告横幅 */}
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px] animate-pulse [animation-duration:3s]"> <div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" /> <AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight"> <span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
{t('storageCleaner:irreversible')} {t('storageCleaner:irreversible')}
+2 -2
View File
@@ -46,7 +46,7 @@ export default function StorageOptionsGrid({
return ( return (
<div <div
className={cn( className={cn(
'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all', 'w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
className, className,
)} )}
{...props} {...props}
@@ -75,7 +75,7 @@ export default function StorageOptionsGrid({
*/} */}
<div <div
onClick={handleToggleAll} onClick={handleToggleAll}
className="border-t border-border flex justify-between items-center px-4 py-2.5 bg-muted/20 hover:bg-muted/50 transition-colors cursor-pointer select-none" 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"
> >
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer"> <Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer">
{t('storageCleaner:selectAll')} {t('storageCleaner:selectAll')}
+11 -25
View File
@@ -11,52 +11,43 @@ import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function Index() { export default function Index() {
const { t } = useLazyTranslation('storageCleaner'); const { t } = useLazyTranslation('storageCleaner');
// 1. 完美对接全新重构、无需回调参数的纯净版状态 Hook
const { const {
error, error,
isInitializing, isInitializing,
options, options,
sizes, sizes,
autoRefresh, reloadAfterClean,
loading, loading,
result, result,
showConfirm, showConfirm,
setShowConfirm, setShowConfirm,
allSelected, allSelected,
someSelected, someSelected,
handleAutoRefreshChange, handleReloadAfterCleanChange,
handleOptionChange, handleOptionChange,
handleSelectAll, handleSelectAll,
handleClean, handleClean,
} = useStorageCleaner(); } = useStorageCleaner();
// 计算当前的按钮锁定状态:没有任何一项被勾选,或者正在清理中,则禁用大按钮
const isButtonDisabled = !(someSelected || allSelected) || loading; const isButtonDisabled = !(someSelected || allSelected) || loading;
// 2. 初始化骨架屏:全面升级为符合 shadcn 规范的无损微动效 Spinner
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground/80" /> <Loader2 className="h-6 w-6 text-muted-foreground/80" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
... {t('storageCleaner:initializing')}
</span> </span>
</div> </div>
); );
} }
// 拦截非法域名或受限域名的错误提示页(ErrorDisplay 内部已在上一轮做好 p-4 居中)
if (error) { if (error) {
return <ErrorDisplay error={error} />; return <ErrorDisplay error={error} />;
} }
return ( return (
/* 3. 终极版页面根容器: <div className="p-4 w-full flex flex-col space-y-3.5">
- 彻底灌入 p-4,全局对齐线向内收缩 16px,终结贴边惨剧。
- 用 space-y-3.5 替代块级外边距(mb-3 等),让所有卡片之间的垂直间距处于绝对一致的黄金比例。
*/
<div className="p-4 w-full flex flex-col space-y-3.5 animate-in fade-in duration-300">
{/* 存储网格核心中控面板 (内部已满血复活半选状态) */}
<StorageOptionsGrid <StorageOptionsGrid
options={options} options={options}
sizes={sizes} sizes={sizes}
@@ -66,20 +57,17 @@ export default function Index() {
onSelectAll={handleSelectAll} onSelectAll={handleSelectAll}
/> />
{/* 自动刷新开关 */} <AutoRefreshToggle
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} /> reloadAfterClean={reloadAfterClean}
onChange={handleReloadAfterCleanChange}
/>
{/* 4. 主行动按钮超进化:
- 彻底剥离 bg-amber-500,全面回归系统标准的 variant="destructive"。
- 享受高风险操作该有的危险红警示,完美契合上一轮重构的二次确认弹窗基调。
- 高级动态加载:当处于 cleaning 状态时,文字自动流转,且左侧自动淡入等宽 Loader2 图标。
*/}
<Button <Button
variant="destructive" variant="destructive"
size="default" size="default"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
disabled={isButtonDisabled} disabled={isButtonDisabled}
className="w-full h-10 font-bold shadow-sm text-sm tracking-wide transition-all active:scale-[0.99]" className="w-full h-10 font-bold shadow-sm text-sm tracking-wide"
> >
{loading ? ( {loading ? (
<> <>
@@ -91,10 +79,8 @@ export default function Index() {
)} )}
</Button> </Button>
{/* 动态清理结果返回反馈卡片 */}
<CleaningResult result={result} /> <CleaningResult result={result} />
{/* 二次风险防御确认弹窗 */}
<StorageCleanerConfirm <StorageCleanerConfirm
open={showConfirm} open={showConfirm}
onClose={() => setShowConfirm(false)} onClose={() => setShowConfirm(false)}
+13 -19
View File
@@ -30,7 +30,7 @@ const DEFAULT_OPTIONS: StorageCleanerOptions = {
}; };
const DEFAULT_PREFERENCES: StorageCleanerPreferences = { const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
autoRefresh: true, reloadAfterClean: true,
selectedTypes: DEFAULT_OPTIONS, selectedTypes: DEFAULT_OPTIONS,
}; };
@@ -40,7 +40,7 @@ export interface UseStorageCleanerReturn {
isInitializing: boolean; isInitializing: boolean;
options: StorageCleanerOptions; options: StorageCleanerOptions;
sizes: Record<string, number>; sizes: Record<string, number>;
autoRefresh: boolean; reloadAfterClean: boolean;
loading: boolean; loading: boolean;
result: CleaningResult | null; result: CleaningResult | null;
showConfirm: boolean; showConfirm: boolean;
@@ -49,7 +49,7 @@ export interface UseStorageCleanerReturn {
allSelected: boolean; allSelected: boolean;
someSelected: boolean; someSelected: boolean;
handleAutoRefreshChange: (checked: boolean) => void; handleReloadAfterCleanChange: (checked: boolean) => void;
handleOptionChange: (key: keyof StorageCleanerOptions) => void; handleOptionChange: (key: keyof StorageCleanerOptions) => void;
handleSelectAll: (checked: boolean) => void; handleSelectAll: (checked: boolean) => void;
handleClean: () => Promise<void>; handleClean: () => Promise<void>;
@@ -62,7 +62,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const [isInitializing, setIsInitializing] = useState<boolean>(true); const [isInitializing, setIsInitializing] = useState<boolean>(true);
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [sizes, setSizes] = useState<Record<string, number>>({}); const [sizes, setSizes] = useState<Record<string, number>>({});
const [autoRefresh, setAutoRefresh] = useState<boolean>(true); const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null); const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false); const [showConfirm, setShowConfirm] = useState<boolean>(false);
@@ -117,7 +117,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (currentRequestId !== requestIdRef.current) return; if (currentRequestId !== requestIdRef.current) return;
if (savedPrefs) { if (savedPrefs) {
setAutoRefresh(savedPrefs.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh); setReloadAfterClean(savedPrefs.reloadAfterClean ?? DEFAULT_PREFERENCES.reloadAfterClean);
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes); setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
} }
@@ -170,26 +170,22 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}; };
}, [debouncedLoadInfo]); }, [debouncedLoadInfo]);
// 2. 超进化:防抖写盘管道 (Chrome Storage Debounce Pipeline)
// 用户疯狂点击勾选时,React 状态保持丝滑的 0 延迟同步,只有停下点击 500ms 后,才会真正发起一次 Chrome 存盘,配额永远安全。
useEffect(() => { useEffect(() => {
// 过滤掉首次初始化时的无意义写盘
if (isInitializing) return; if (isInitializing) return;
if (storageTimerRef.current) clearTimeout(storageTimerRef.current); if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
storageTimerRef.current = setTimeout(async () => { storageTimerRef.current = setTimeout(async () => {
await storageUtil await storageUtil
.set('storageCleaner/preferences', { .set('storageCleaner/preferences', {
autoRefresh, reloadAfterClean,
selectedTypes: options, selectedTypes: options,
}) })
.catch(console.error); .catch(console.error);
}, 500); }, 500);
}, [options, autoRefresh, isInitializing]); }, [options, reloadAfterClean, isInitializing]);
// 3. 极速状态分发:同步函数化(去掉了原有的 async 声明,只负责触发状态) const handleReloadAfterCleanChange = useCallback((checked: boolean) => {
const handleAutoRefreshChange = useCallback((checked: boolean) => { setReloadAfterClean(checked);
setAutoRefresh(checked);
}, []); }, []);
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => { const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
@@ -207,7 +203,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}); });
}, []); }, []);
// 清理动作核心
const handleClean = useCallback(async () => { const handleClean = useCallback(async () => {
if (loadingRef.current) return; if (loadingRef.current) return;
@@ -222,7 +217,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const cleaningResult = await clearStorage(tab.id, tab.url, options); const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult); setResult(cleaningResult);
if (autoRefresh && cleaningResult.success) { if (reloadAfterClean && cleaningResult.success) {
toast.success(t('storageCleaner:cleanSuccessReload')); toast.success(t('storageCleaner:cleanSuccessReload'));
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 }); await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
} else { } else {
@@ -234,9 +229,8 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
setLoading(false); setLoading(false);
setShowConfirm(false); setShowConfirm(false);
} }
}, [options, autoRefresh, loadInfo, t]); }, [options, reloadAfterClean, loadInfo, t]);
// 4. 精准的流式衍生计算收拢:完全切断垃圾内存常态分配
const totalSize = useMemo(() => { const totalSize = useMemo(() => {
return ( return (
(sizes.cookies || 0) + (sizes.cookies || 0) +
@@ -259,7 +253,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
isInitializing, isInitializing,
options, options,
sizes, sizes,
autoRefresh, reloadAfterClean,
loading, loading,
result, result,
showConfirm, showConfirm,
@@ -267,7 +261,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
totalSize, totalSize,
allSelected: selectionMetrics.all, allSelected: selectionMetrics.all,
someSelected: selectionMetrics.some, someSelected: selectionMetrics.some,
handleAutoRefreshChange, handleReloadAfterCleanChange,
handleOptionChange, handleOptionChange,
handleSelectAll, handleSelectAll,
handleClean, handleClean,
+1 -12
View File
@@ -1,6 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { FileText } from 'lucide-react';
import PageHeader from '@/components/PageHeader';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { formatByteSize, getTextStats } from '@/utils/textStatistics'; import { formatByteSize, getTextStats } from '@/utils/textStatistics';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
@@ -28,15 +26,7 @@ export default function Index() {
]; ];
return ( return (
<div className="p-4 w-full space-y-4 animate-in fade-in duration-300"> <div className="p-4 w-full space-y-4">
{/* 头部区域 */}
<PageHeader
title={t('textStatistics:pageTitle')}
subtitle={t('textStatistics:pageSubtitle')}
icon={<FileText />}
iconColor="text-purple-500"
/>
{/* 文本输入区域 */} {/* 文本输入区域 */}
<TextInputArea <TextInputArea
value={text} value={text}
@@ -55,7 +45,6 @@ export default function Index() {
key={item.label} key={item.label}
className={cn( className={cn(
'flex flex-col justify-center items-center p-4 text-center rounded-xl border border-border bg-card shadow-sm text-card-foreground', 'flex flex-col justify-center items-center p-4 text-center rounded-xl border border-border bg-card shadow-sm text-card-foreground',
'transition-all duration-200 ease-out',
'hover:-translate-y-0.5 hover:shadow-md hover:border-primary/50 focus-within:ring-1 focus-within:ring-ring', 'hover:-translate-y-0.5 hover:shadow-md hover:border-primary/50 focus-within:ring-1 focus-within:ring-ring',
)} )}
> >
+3 -4
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Clock } from 'lucide-react'; import { Clock } from 'lucide-react';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar';
import type { UnitType } from '@/config/pageTheme'; import type { UnitType } from './constants';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; // 引入标准的 shadcn 工具函数 import { cn } from '@/lib/utils'; // 引入标准的 shadcn 工具函数
@@ -80,7 +80,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
type="button" type="button"
onClick={handleUseNow} onClick={handleUseNow}
title={t('timestamp:useNowTooltip')} 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 transition-all hover:bg-accent hover:text-foreground active:scale-95 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 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"
> >
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
</button> </button>
@@ -88,8 +88,7 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
<CopyButton <CopyButton
text={currentDisplay.text} text={currentDisplay.text}
tooltip={t('timestamp:copyTsTooltip')} tooltip={t('timestamp:copyTsTooltip')}
size="small" className="h-7 w-7 rounded-md border"
className="h-7 w-7 rounded-md border" // 移除了硬编码的颜色配置表,完全交由组件的内置 Class 渲染
/> />
</div> </div>
); );
+5 -11
View File
@@ -1,8 +1,8 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import dayjs from '@/utils/dayjs'; import dayjs from '@/utils/dayjs';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import type { UnitType } from '@/config/pageTheme'; import type { UnitType } from './constants';
import { DATE_FORMAT } from '@/config/pageTheme'; // 彻底移除了非语义的 timestampPageStyles 依赖 import { DATE_FORMAT } from './constants';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具 import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
@@ -50,7 +50,7 @@ const ResultView = React.memo(
return ( return (
<div <div
className={cn( 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] animate-in fade-in duration-200', '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, className,
)} )}
{...props} {...props}
@@ -61,13 +61,7 @@ const ResultView = React.memo(
} }
return ( return (
<div <div className={cn('flex flex-col w-full', className)} {...props}>
className={cn(
'animate-in fade-in slide-in-from-bottom-2 duration-300 flex flex-col w-full',
className,
)}
{...props}
>
{/* 顶部小标签 */} {/* 顶部小标签 */}
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase"> <span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
{t('timestamp:resultLabel')} {t('timestamp:resultLabel')}
@@ -78,7 +72,7 @@ const ResultView = React.memo(
对齐 shadcn 官方卡片风格,使用 bg-card、border-border 构筑多层级阴影。 对齐 shadcn 官方卡片风格,使用 bg-card、border-border 构筑多层级阴影。
核心数值直接拉粗为 text-foreground (在黑夜模式下会自动转为大气的纯白,完美避开刺眼强光) 核心数值直接拉粗为 text-foreground (在黑夜模式下会自动转为大气的纯白,完美避开刺眼强光)
*/} */}
<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 transition-all"> <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"> <span className="font-mono font-extrabold text-foreground break-all text-xl sm:text-2xl tracking-tight leading-tight select-all tabular-nums">
{result} {result}
</span> </span>
+6
View File
@@ -0,0 +1,6 @@
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
+3 -16
View File
@@ -1,7 +1,5 @@
import { Clock } from 'lucide-react';
import PageHeader from '@/components/PageHeader';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { ZONES } from '@/config/pageTheme'; import { ZONES } from './constants';
import LiveClock from './LiveClock'; import LiveClock from './LiveClock';
import ResultView from './ResultView'; import ResultView from './ResultView';
import { useTimestampConverter } from './useTimestampConverter'; import { useTimestampConverter } from './useTimestampConverter';
@@ -39,13 +37,6 @@ export default function Index() {
return ( return (
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20"> <div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
<div className="max-w-7xl mx-auto p-4 sm:p-6 space-y-4"> <div className="max-w-7xl mx-auto p-4 sm:p-6 space-y-4">
{/* Header */}
<PageHeader
title={t('timestamp:pageTitle')}
subtitle={t('timestamp:pageSubtitle')}
icon={<Clock />}
/>
{/* 动态参考信息时钟条 */} {/* 动态参考信息时钟条 */}
<LiveClock unit={unit} onUseNow={handleUseNow} /> <LiveClock unit={unit} onUseNow={handleUseNow} />
@@ -79,17 +70,13 @@ export default function Index() {
value={input} value={input}
onChange={(e: { target: { value: string } }) => setInput(e.target.value)} onChange={(e: { target: { value: string } }) => setInput(e.target.value)}
className={cn( className={cn(
'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background transition-all', 'font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background',
error && 'border-destructive focus-visible:ring-destructive', error && 'border-destructive focus-visible:ring-destructive',
)} )}
/> />
{/* 错误自愈提示 */} {/* 错误自愈提示 */}
{error && ( {error && <p className="text-destructive text-xs font-medium px-0.5">{error}</p>}
<p className="text-destructive text-xs font-medium px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
{error}
</p>
)}
</div> </div>
{/* 核心配置群:单位切换 + 时区选择紧凑横排 */} {/* 核心配置群:单位切换 + 时区选择紧凑横排 */}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import dayjs from '@/utils/dayjs'; import dayjs from '@/utils/dayjs';
import type { UnitType, ZoneType } from '@/config/pageTheme'; import type { UnitType, ZoneType } from './constants';
import { DATE_FORMAT } from '@/config/pageTheme'; import { DATE_FORMAT } from './constants';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { useContextMenuData } from '@/utils/useContextMenuData'; import { useContextMenuData } from '@/utils/useContextMenuData';
+63
View File
@@ -0,0 +1,63 @@
# providers/
React Context Provider 目录,为整个应用提供全局共享状态。
## 文件说明
| 文件 | 用途 |
| ----------------------- | ---------------------- |
| `AppRoot.tsx` | 应用根 Provider 组合器 |
| `RouterProvider.tsx` | 路由 Context Provider |
| `ThemeModeProvider.tsx` | 主题模式 Provider |
## AppRoot.tsx
应用根 Provider 组合器,按顺序包裹:
```
React.StrictMode
└── ThemeModeProvider
└── RouterProvider
└── children
```
## RouterProvider.tsx
路由 Context Provider,管理:
- **当前页面**`currentPage``PageType`
- **可见页面列表**`visiblePages`
- **页面排序**`pageOrder`
- **加载状态**`isLoaded`
核心特性:
- 通过 `chrome.storage` 持久化路由状态
- 使用 `localStorage` 快照实现首屏 0 闪烁
- 支持 popup/sidepanel/tab 三种入口的独立路由同步(通过 `syncKey``visiblePagesKey``pageOrderKey` 配置)
- 处理右键菜单待处理数据的路由跳转
- 监听 `chrome.storage.onChanged` 实现跨端同步
导出:
- `RouterProvider` 组件
- `useRouter()` Hook — 获取 `currentPage``visiblePages``pageOrder``navigateTo``goBack`
## ThemeModeProvider.tsx
主题模式 Provider,管理:
- **主题模式**`light` / `dark` / `system`
- **解析后的主题**`resolvedTheme``light` / `dark`
核心特性:
- 使用 `localStorage` 快照实现首屏 0 闪烁
- 监听系统级暗色模式变化(`matchMedia`
- 通过 `chrome.storage` 跨端同步主题偏好
- 自动在 `document.documentElement` 上切换 `dark` class
导出:
- `ThemeModeProvider` 组件
- `useThemeMode()` Hook — 获取 `themeMode``resolvedTheme``setThemeMode`
+20
View File
@@ -0,0 +1,20 @@
# public/
静态资源目录,存放无需构建处理的文件,会被直接复制到输出目录。
## 文件说明
| 文件/目录 | 用途 |
| -------------- | -------------------------------- |
| `icon/` | 扩展图标,提供多种尺寸 |
| `icon/16.png` | 16×16 图标(工具栏) |
| `icon/32.png` | 32×32 图标 |
| `icon/48.png` | 48×48 图标(扩展管理页) |
| `icon/96.png` | 96×96 图标 |
| `icon/128.png` | 128×128 图标(Chrome Web Store |
| `wxt.svg` | WXT 框架标志 SVG 图标 |
## 注意事项
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
- 图标格式推荐使用 PNG,确保透明背景
+24
View File
@@ -0,0 +1,24 @@
# src/
源码目录,存放全局样式定义。
## 文件说明
| 文件 | 用途 |
| ----------- | ----------------- |
| `index.css` | 全局 CSS 入口文件 |
## index.css
全局样式入口,包含:
- **Tailwind 指令**`@tailwind base/components/utilities`
- **shadcn/ui CSS 变量**:定义 `--background``--primary``--destructive``--card``--muted``--accent``--border``--ring` 等语义化颜色变量
- **主题色值**`:root`(亮色)和 `.dark`(暗色)两套完整的颜色定义
- **圆角变量**`--radius` 定义全局圆角大小
## 修改注意事项
- 修改 CSS 变量会影响所有使用 shadcn/ui 语义化 token 的组件
- 新增颜色变量需同时在 `:root``.dark` 中定义
- 避免在组件中硬编码颜色值,应使用 CSS 变量或 Tailwind 的语义化类名
+1 -5
View File
@@ -26,11 +26,7 @@
"target": "ESNext", "target": "ESNext",
"types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"], "types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"],
"noImplicitAny": true, "noImplicitAny": true
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}, },
// 确保包含你的源代码目录 // 确保包含你的源代码目录
"include": [ "include": [

Some files were not shown because too many files have changed in this diff Show More