Compare commits

..

104 Commits

Author SHA1 Message Date
雨霖铃 77e74e31d3 feat: adjust Typography variant in LiveClock component and refine nav-container margin 2026-03-22 15:15:58 +08:00
雨霖铃 c70b5d1ab5 feat: comprehensive UI/UX refactor for TimestampPage with performance optimizations 2026-03-22 14:09:32 +08:00
雨霖铃 1fdbd22167 Merge branch 'main' of https://github.com/LingandRX/testing-tool 2026-03-21 09:52:53 +08:00
雨霖铃 671c7c677f feat: update permissions to include additional Bash commands 2026-03-21 09:48:19 +08:00
雨霖铃 3bac87717f feat: add route persistence and page visibility configuration
- Persist current route to Chrome Storage, restore on popup reopen
- Add page visibility configuration to control which pages are shown
- Update StorageSchema with new storage keys (app/currentRoute, app/visiblePages)
- Add PageType type and PAGE_CONFIG for type-safe page management
- Add navigation button styles with active state and hover effects
- Use loading state to prevent UI flicker during async data load

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 09:46:41 +08:00
雨霖铃 33b16852e7 Merge branch 'main' of https://github.com/LingandRX/testing-tool 2026-03-20 21:47:19 +08:00
雨霖铃 b4a3bbfebb feat: 添加 AGENTS.md 文件以提供项目指导和核心功能说明 2026-03-20 21:45:35 +08:00
雨霖铃 b56674b83b feat: complete storage cleaner feature implementation
All tasks completed:
- Add TypeScript types for storage cleaner
- Add cookies permission to manifest
- Add storage cleaning utility functions
- Add StorageCleanerPage component
- Add tab switching to App component
- Build and test successful

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 02:45:47 +08:00
雨霖铃 a0854da80c feat: add tab switching to App component
- Add PageType type definition
- Add currentPage state
- Add tab switching buttons
- Add conditional rendering of pages

Co-Authored-By: Claude Op. 4.6 <noreply@anthropic.com>
2026-03-20 01:16:57 +08:00
雨霖铃 63c8fe805e feat: add StorageCleanerPage component 2026-03-20 01:09:36 +08:00
雨霖铃 83dbf0b3e9 feat: add storage cleaning utility functions 2026-03-20 01:03:30 +08:00
雨霖铃 2faa66a880 feat: add cookies permission to manifest 2026-03-20 00:56:59 +08:00
雨霖铃 de3ae93ea9 feat: add TypeScript types for storage cleaner 2026-03-20 00:53:38 +08:00
雨霖铃 480af5f969 docs: update implementation plan with code review feedback
- Add Button import in App.tsx
- Enhance confirmation dialog with selected storage types
- Add color="error" to confirm button
- Add httponly/secure cookies test to checklist
- Add localhost test to checklist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:45:00 +08:00
雨霖铃 f530cfa7ac docs: fix typo in implementation plan
- Fix subskill name typo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:38:19 +08:00
雨霖铃 1e4a9375df docs: add storage cleaner implementation plan
- Add TypeScript types task
- Add cookies permission task
- Create storage cleaning utilities task
- Create StorageCleanerPage component task
- Update App.tsx with tab switching task
- Build and test task
- Add testing checklist and rollback plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:37:20 +08:00
雨霖铃 8f53849429 docs: remove duplicate TypeScript type definitions
- Fix duplicate StorageCleanerOptions, StorageCleanResult, and CleaningResult definitions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:31:39 +08:00
雨霖铃 a7c6fb6339 docs: add user preferences and more edge case handling
- Add user preferences persistence section
- Add file:// and data:// to restricted protocols
- Add empty state UI handling
- Add refresh delay for better UX
- Update TypeScript types for StorageSchema integration
- Add more test cases for edge scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:29:17 +08:00
雨霖铃 6f0031cf74 docs: fix duplicate content and typo in spec
- Fix duplicate IndexedDB.databases method call typo
- Remove duplicate Popup lifecycle notes section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:24:22 +08:00
雨霖铃 11af171212 docs: update storage cleaner design with code review feedback
- Add routing implementation details
- Add restricted page detection
- Add complete executeScript examples
- Add error handling for IndexedDB.databases
- Add Service Workers multiple registrations handling
- Add popup lifecycle notes
- Improve TypeScript types with error handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:22:34 +08:00
雨霖铃 d2dc0057b3 docs: add storage cleaner feature design document
- Design for browser extension storage cleaning feature
- Supports localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, Service Workers
- Uses chrome.cookies API for cookie cleaning
- Implements IndexedDB onblocked event handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 00:18:02 +08:00
雨霖铃 a9021b6397 Refactor code structure for improved readability and maintainability 2026-03-19 08:47:15 +08:00
雨霖铃 050621d3dc refactor: 精简时间戳功能为简约单页面
- 将3个时间戳组件合并为一个简约页面
- 移除路由、导航栏等不必要组件
- 预填充当前时间/时间戳
- 始终显示结果框
- 清理测试文件和依赖
2026-03-19 00:18:09 +08:00
雨霖铃 9db9481ef4 refactor: 移除测试页面路由配置 2026-03-19 00:01:46 +08:00
雨霖铃 dec0da2ff3 refactor: 移除测试页面 2026-03-18 23:58:55 +08:00
雨霖铃 4d52cbb12f refactor: 移除所有录制功能代码
- 删除录制相关页面组件(RecordReplayPage、ReplayListPage、ReplayPlayerPage)
- 删除录制工具文件(recordEventsDb、recordUtils、useRecorder、tabUtils)
- 清理 background.ts 和 content.ts 中的录制代码
- 更新 App.tsx 移除录制路由
- 清理 messages.tsx 中的消息定义
- 删除 types.tsx 中的录制状态
- 移除 package.json 中的 rrweb 相关依赖
- 更新 wxt.config.ts 移除 offscreen、downloads 权限
- 删除 offscreen 目录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:57:32 +08:00
雨霖铃 789705c33b feat: 添加内置回放界面和错误修复 2026-03-16 23:40:56 +08:00
雨霖铃 9ff6b8985c refactor: 优化组件并添加测试覆盖
- 创建 CLAUDE.md 项目开发指导文档
- 提取公共常量到 components/constants.ts
- 修复 TimestampToDatetime 重复插件扩展问题
- 修复 DatetimeToTimestamp 时区解析问题
- 优化 RoutePersistence 移除不必要依赖
- 添加 Vitest 测试框架配置
- 为所有组件编写单元测试 (16个测试用例)
- 更新 .gitignore 忽略测试结果目录

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 22:10:02 +08:00
LingandRX c9f8fe4d29 Merge pull request #3 from LingandRX/develop
refactor(storage): 将 storage 工具类导出名重命名为 storageUtil
2026-02-27 16:17:23 +08:00
LingandRX 6458500d5a Merge pull request #2 from LingandRX/develop
Develop
2026-02-27 16:14:59 +08:00
雨霖铃 f0bb1b21ab refactor(storage): 将 storage 工具类导出名重命名为 storageUtil
- 将 utils/chromeStorage.ts 中的 storage 导出重命名为 storageUtil
- 更新 RoutePersistence.tsx 中的导入和引用以使用新名称
- 优化 recordUtils.tsx 中 chrome.downloads 的链式调用格式

使工具类的命名更加清晰,避免与全局 storage 概念混淆
2026-02-27 16:12:52 +08:00
雨霖铃 6d2b553ca4 ```
refactor(storage): 将storage工具类重命名为chromeStorage

重命名utils/storage.ts为utils/chromeStorage.ts以更好地反映其功能用途

feat(messages): 简化消息协议定义并移除过时的消息类型

将messages.tsx中的硬编码消息对象替换为ProtocolMap接口定义,
移除了不再使用的popup、content和offscreen相关消息类型,
使消息系统更加简洁和类型安全
```
2026-02-26 17:15:20 +08:00
雨霖铃 c848003214 feat(recorder): 实现IndexedDB分块存储录制事件
- 将录制状态中的events数组替换为sessionId,避免内存溢出问题
- 集成IndexedDB工具模块实现事件分块存储功能
- 修改开始录制逻辑以生成会话ID并初始化数据库会话
- 更新停止录制逻辑从数据库流式读取事件并删除会话数据
- 修复content:save-track-events消息类型的拼写错误
- 在RoutePersistence组件中添加路由恢复和保存的成功提示
- 重构popup应用CSS样式文件,移除冗余导航样式代码
2026-02-21 15:27:30 +08:00
雨霖铃 80e510225c feat(background): 添加录制状态持久化和Tab切换处理
- 实现录制状态存储到本地storage,包括isRecording、recordingTabId、events和startTime
- 添加loadRecorderState和saveRecorderState函数用于状态管理
- 监听Tab切换事件,当切换离开录制Tab时发出警告并通知popup
- 监听Tab关闭事件,自动停止录制并清理状态
- 添加录制状态同步检查,确保content script状态一致
- 在开始录制前检查是否已在录制状态,避免重复录制
- 添加错误处理返回详细错误信息
- 优化回放HTML样式和脚本加载方式
- 添加Tab切换消息通信协议支持
2026-02-21 15:09:07 +08:00
雨霖铃 19b7ab141a feat(extension): 添加调试器功能并优化界面布局
- 集成 Chrome 调试器 API 支持 attach/detach 功能
- 新增 tabUtils 工具模块用于获取活动标签页 ID
- 在背景脚本中添加标签页激活和更新事件监听
- 使用 Material-UI 容器和堆叠组件重构页面布局
- 移除 Chromium 启动参数中的自动打开开发者工具选项
- 更新权限配置添加调试器相关权限
- 在测试页面添加多个调试功能按钮和状态管理
2026-02-15 01:35:23 +08:00
雨霖铃 c01659c9f9 docs(readme): 更新项目文档以反映浏览器扩展功能
- 将原有 Create React App 模板文档替换为 Testing Tools 浏览器扩展介绍
- 添加项目概述、功能特性和技术栈说明
- 提供详细的安装运行指南和构建命令
- 补充项目结构、权限说明和主要依赖信息
- 增加贡献指南和许可证说明
2026-02-14 01:53:27 +08:00
雨霖铃 3ece5e2e96 fix: 修复 ESLint 配置及组件弃用属性警告,优化代码格式
1. 工程配置:
- 更新 tsconfig.json include 规则,解决 eslint.config.ts 未被包含的问题。

2. 组件重构 (Material UI 适配):
- Navbar: 替换已弃用的 PaperProps 为 slotProps.paper。
- TimestampToDatetime / DatetimeToTimestamp: 替换已弃用的 InputProps 为 slotProps.input。

3. 类型与逻辑修复:
- utils/recordUtils.tsx: 引入缺失依赖,忽略 UNSAFE_replayCanvas 等类型报错,修复构建问题。
- utils/messages.tsx: 完善事件参数类型定义。

4. 其他优化:
- 统一 App.css 代码格式。
- 补充部分组件 (CopyButton, TimestampExecution) 的 React 导入和类型定义。
2026-02-13 23:46:16 +08:00
雨霖铃 50034331bc refactor: 统一扩展消息机制并优化组件结构
1、统一消息系统:将 background、content 和 popup 的通信方式从原生的 chrome.runtime 迁移至 @webext-core/messaging,修复了消息回调返回 undefined 的问题。
2、优化组件结构:将通用组件从 entrypoints/popup/components 迁移至根目录 components,并使用 MUI 重构了 Navbar。
3、同步更新:调整了录制工具 (useRecorder) 和各页面组件(RecordeReplayPage, TestPage等),以适配新的消息协议和组件路径。
2026-02-13 20:38:15 +08:00
雨霖铃 22985f4c18 feat:添加测试页面并优化时间戳工具
refactor: 优化时间戳转换组件及整体代码结构
chore: 更新构建配置和包依赖项
2026-02-13 17:29:01 +08:00
雨霖铃 ee4d328b9c refactor:
1、添加全局toast
2、将CopyButton拆分为useCopy hook
2026-02-13 00:24:56 +08:00
雨霖铃 a93a8b3e9a refactor:优化时间戳工具,优化CopyButton 2026-02-12 21:26:41 +08:00
雨霖铃 38b7d16c31 feat: 添加导航栏组件,优化路由持久化逻辑,更新存储工具类 2026-02-07 13:17:32 +08:00
雨霖铃 a94ae7421e feat:
1、并发进行脚本注入
2、完善过滤逻辑
2026-02-07 00:31:41 +08:00
雨霖铃 2c0df95104 feat: 添加录制功能,优化消息处理,修复拼写错误,更新下载逻辑 2026-02-04 23:29:42 +08:00
雨霖铃 e6f41032c8 feat: 重构录制逻辑,添加错误处理,更新消息常量,使用 terser 进行构建压缩 2026-02-03 23:42:28 +08:00
雨霖铃 37f8ef9c2b feat: 优化消息处理逻辑,添加录制开始和停止的消息处理,删除未使用的 chromeUtils 文件 2026-02-02 22:57:01 +08:00
雨霖铃 a6d85962b7 feat: 使用 dayjs 替换时间处理逻辑,优化日期时间转换功能,删除不再使用的时间工具函数 2026-02-02 20:58:56 +08:00
雨霖铃 0b70c0c0c1 feat:
1、移动popup的page、components
2、更新wxt的host权限
3、验证message传递逻辑
2026-02-01 00:21:36 +08:00
雨霖铃 4213e70697 feat: update package.json to remove lint-staged, add eslint configuration and dependencies
feat: add state management for recording in RecordeReplayPage component

refactor: remove bridge and storage services as they are no longer needed

chore: update tsconfig.json for improved type checking and module resolution

fix: update downloadHtml function to use eventWithTime type and improve formatting

fix: add error logging in formatWithZone function for better debugging
2026-01-31 18:00:01 +08:00
雨霖铃 88a612f9cf feat: 更新背景脚本和内容脚本,添加录制控制消息处理,优化录制状态检查逻辑,新增消息常量 2026-01-30 23:08:49 +08:00
雨霖铃 0ff33cbeb3 feat: 添加离屏文档创建和下载功能,优化录制逻辑 2026-01-29 19:35:55 +08:00
雨霖铃 ed5e90f0fc feat: 删除不再使用的数据库和环境相关的工具函数 2026-01-29 17:27:18 +08:00
雨霖铃 92026ee7fa Refactor time utility functions for improved type safety and error handling
- Removed deprecated formatDate function.
- Enhanced formatWithZone to support TypeScript types for parameters.
- Updated getTimeZoneOffset to use TypeScript types.
- Improved formatWithDate for better error messages and type safety.
2026-01-28 22:39:44 +08:00
雨霖铃 710bffd2f8 feat: 添加 husky 和 lint-staged 支持,配置 prettier 进行代码格式化 2026-01-28 20:26:48 +08:00
雨霖铃 4edb2f1a47 feat:
1. 重构CopyButton
2. 修改Router特性
3. 修改CopyButton传参
4. 添加options页面
2026-01-28 00:42:05 +08:00
雨霖铃 b33194bd2d 更换使用wxt框架搭建testing tool 2026-01-27 23:09:57 +08:00
雨霖铃 9c84b7adc0 feat(deps): 添加 rrweb 控制台记录和回放插件支持
- 集成 @rrweb/rrweb-plugin-console-record 插件用于控制台记录功能
- 集成 @rrweb/rrweb-plugin-console-replay 插件用于控制台回放功能
- 更新 package-lock.json 中的相关依赖配置
- 添加 tailwindcss 依赖的 yaml 模块支持
2026-01-25 22:25:42 +08:00
雨霖铃 a53b58a007 feat(app): add rrweb recording and replay functionality
- Integrate rrweb library for session recording capabilities
- Add RecordeReplayPage component with start/stop recording buttons
- Implement useRecorder hook to manage recording state and events
- Create bridge service for extension communication
- Add storage service with Chrome extension and web compatibility
- Implement downloadHtml utility for replay file generation
- Update manifest permissions for activeTab, scripting, and tabs
- Add Chrome extension type definitions as development dependencies
2026-01-25 12:36:52 +08:00
雨霖铃 49aaab6e2c Comment out automated test step in workflow
Disables the test command in the CI pipeline, possibly to address failing tests, speed up builds, or troubleshoot issues with the testing environment.
2026-01-21 23:30:39 +08:00
雨霖铃 b6eeb74fed Comments out sample React test code
Temporarily disables default test to prevent it from running,
likely in preparation for custom tests or to avoid irrelevant
test failures during development.
2026-01-21 23:27:18 +08:00
LingandRX 1c258e2bf4 Change npm ci to npm install in workflow 2026-01-21 23:24:13 +08:00
LingandRX de8e6c6139 Update Node.js version matrix in workflow 2026-01-21 23:22:55 +08:00
雨霖铃 2665d6ab81 Improves UI alignment and copy feedback clarity
Removes unnecessary spacing and minimum height from layout styles
to enhance visual consistency. Updates copy feedback to display a
clearer success message, improving user interaction and clarity.
2026-01-20 23:01:56 +08:00
雨霖铃 f6432dc4c4 Standardizes UI with CSS variables and refactors styles
Introduces CSS custom properties for unified colors, border, and radius,
simplifying style management and improving maintainability. Refactors
timestamp and datetime converter styles to use these variables for
consistency. Enhances visual feedback with hover effects and minor
typography adjustments. Updates a timer ref type annotation for clarity
and adds a container class for better structure.
2026-01-20 00:06:01 +08:00
雨霖铃 d8a9e2e1e7 Removes test and todo pages from app navigation
Cleans up navigation by excluding test and todo-related pages and their associated files.
Simplifies the app structure to focus on the timestamp feature and prepares for future refactoring or feature updates.
2026-01-19 23:14:32 +08:00
雨霖铃 c37d721aad feat: implement Markdown Todo Tree with date grouping and local storage persistence
- Added new CSS styles for the TestPage component.
- Refactored TestPage.js to manage a Markdown-based todo list with date grouping.
- Integrated date-fns for date formatting.
- Introduced ReactMarkdown for rendering Markdown content.
- Enhanced user experience with input handling for adding and editing todos.
- Implemented local storage for persistent state across sessions.
2026-01-12 23:19:20 +08:00
雨霖铃 db46210096 fix:修复复制按钮 2026-01-12 22:14:01 +08:00
雨霖铃 7079334562 feat(app): 添加图片采集器功能并修复粘贴问题
- 集成 dexie 和 dexie-react-hooks 实现本地数据库存储
- 创建 TestPage 组件实现图片粘贴采集功能
- 修复重复粘贴问题,添加事件监听器清理机制
- 修复删除按钮触发增加的事件冒泡问题
- 为电子木鱼页面添加顶部边距样式
- 更新导航项标签为中文显示
- 添加图片网格展示和删除功能
2026-01-08 21:49:39 +08:00
雨霖铃 3ee28a91e0 style(components): 优化导航栏和电子木鱼组件的UI样式
- 调整CopyButton组件的圆角样式
- 更新导航栏的高度、背景、边框和阴影样式
- 优化导航链接的颜色、字体和悬停效果
- 改进折叠菜单的内容样式和动画效果
- 调整时间戳控件的响应式布局
- 优化电子木鱼组件的整体视觉效果
- 更新电子木鱼计数显示的样式和动画
- 改进控制区域输入框和按钮的样式
- 添加多设备尺寸的响应式设计适配
2026-01-08 21:00:26 +08:00
雨霖铃 8956f86443 style(css): 优化页面滚动条和布局样式
- 修改html和body选择器以正确设置页面尺寸和滚动行为
- 将.app容器的高度设置从100vh改为min-height: 100%
- 更新.nav-collapse-content的最大高度计算方式
- 为body元素添加滚动条隐藏样式
- 统一各组件的滚动条样式处理规则
2026-01-07 23:43:17 +08:00
雨霖铃 259bc51dc5 style(ui): 隐藏页面滚动条并优化滚动处理
- 添加html和body的overflow hidden样式以隐藏滚动条
- 在.app容器中添加overflow auto以支持内容滚动
- 为.app、.nav-collapse-content.show和.timestamp-display组件添加滚动条隐藏样式
- 包含WebKit、Firefox和IE/Edge浏览器的兼容性样式
- 修复了页面滚动条显示问题
2026-01-07 23:34:18 +08:00
雨霖铃 cef700a89b style(css): 移除导航折叠组件的边框样式
- 移除了导航折叠项的 border-block 样式
- 移除了导航折叠内容区域的 border-block 样式
- 移除了导航折叠内容区域的 border-top 样式
- 将导航折叠内容区域的边框设置为 border: 0
2026-01-07 23:25:34 +08:00
雨霖铃 cdd197bff5 style(App): 优化导航折叠组件的边框样式
- 移除了多余的左右边框样式设置
- 将边框重置方式从 border: none 改为 border-style: none
- 修正了 border-block 属性值,从 0px 改为 0
2026-01-07 23:15:10 +08:00
雨霖铃 2017dfddfa style(css): 调整导航组件样式
- 移除下拉菜单的 z-index 属性
- 为导航折叠内容添加左右边框样式
- 简化边框隐藏的 CSS 规则
- 添加 border-block 样式属性
- 移除导航边框的 z-index 层级设置
2026-01-07 23:08:47 +08:00
雨霖铃 2673a86a52 feat(App): 添加导航菜单折叠功能和样式优化
- 实现响应式导航菜单,根据屏幕尺寸自动调整显示项数量
- 添加移动端友好的折叠菜单和汉堡按钮
- 统一应用内所有按钮和容器的圆角样式为8px
- 重构导航配置为数据驱动模式,便于后续扩展
- 优化小屏幕设备上的导航显示和交互体验
- 添加菜单展开收起的动画过渡效果
- 修复超小屏幕下的边框遮挡问题
2026-01-07 23:01:57 +08:00
雨霖铃 11e941d5fe feat(components): 添加时间戳和日期时间转换功能
- 实现了时间戳实时显示组件,支持毫秒/秒单位切换和自动更新
- 添加了日期时间转时间戳转换器,支持多种时区选择和单位转换
- 实现了时间戳转日期时间转换器,支持毫秒/秒单位和时区转换
- 为App.css添加了响应式样式和时间戳组件相关样式
- 为时间戳和转换组件添加了错误处理和输入验证功能
- 实现在index.css中重置标题样式以保持一致性
2026-01-07 21:42:59 +08:00
雨霖铃 0cad722479 feat(markdown): 引入 react-markdown 及相关依赖支持 markdown 渲染
- 添加 react-markdown 库以支持 markdown 内容渲染
- 引入 @types 相关类型定义包,包括 debug、estree-jsx、hast 等
- 添加解析 markdown 所需的工具库,如 micromark、mdast-util 系列
- 集成 hast-util-to-jsx-runtime 实现 hast 到 jsx 的转换
- 添加字符实体处理和样式解析相关依赖
- 引入支持 mdx 语法的相关工具包
- 添加统一的类型检查和开发依赖支持
- 更新 package-lock.json 锁定新增依赖版本
2025-12-20 20:58:46 +08:00
雨霖铃 d6b1edeffc feat(electronic-wooden-fish): 重构木鱼页面交互与样式
- 使用CSS变量统一管理主题样式,提升可维护性
- 优化木鱼点击动画效果,增加缩放反馈
- 改进计数逻辑,支持自定义计数名称并持久化存储
- 实现浮动提示动画,增强用户点击反馈
- 添加防抖存储机制,减少频繁IO操作
- 限制提示数量,防止内存泄漏
- 增加响应式设计,适配移动端显示
- 完善组件卸载清理逻辑,避免内存泄漏
- 修复计数存储可能存在的竞态条件问题
- 提升代码健壮性,增加异常处理机制
2025-12-20 14:58:41 +08:00
雨霖铃 11f5e7eeb4 style(App): 调整导航栏样式
- 在导航栏容器中增加底部边距5px
2025-12-20 11:49:14 +08:00
雨霖铃 6dc71bf056 feat(electronic-wooden-fish): 实现电子木鱼页面及本地存储适配器
- 新增电子木鱼点击动画与计数功能
- 添加浮动提示动画效果
- 实现计数名称自定义输入框
- 增加重置所有数据按钮
- 创建跨平台本地存储适配器(支持浏览器和Chrome扩展)
- 样式优化:按钮增加过渡动画和按下效果
- 页面结构重构,分离CSS样式文件
2025-12-20 11:43:35 +08:00
雨霖铃 d7740a3d4f feat(app): 添加电子木鱼页面和导航栏
- 新增电子木鱼页面组件,包含点击动画效果
- 实现导航栏组件,支持路由切换高亮显示
- 更新App.js引入新的页面和导航组件
- 修改DatetimeToTimestamp组件样式布局
- 替换原navbar样式为新的nav导航样式
- 添加NavLink实现路由链接激活状态样式
2025-12-20 00:41:17 +08:00
雨霖铃 d05a8a7065 feat(timestamp): 实现时间戳与日期时间转换功能
- 新增日期时间转时间戳组件 DatetimeToTimestamp
- 新增时间戳转日期时间组件 TimestampToDatetime
- 新增当前时间戳显示组件 TimestampExecution
- 重构 TimestampPage 页面,拆分功能到独立组件
- 扩展 timeUtils 工具函数,支持时区处理和格式化
- 添加时区选择器和时间单位切换功能
- 优化时间戳转换逻辑,支持毫秒和秒单位切换
- 改进 App.css 样式宽度设置
2025-12-19 22:25:27 +08:00
雨霖铃 4f1a78ebb5 refactor(timestamp): 优化时间戳页面逻辑和UI结构
- 移除未使用的 ActionsPage 页面及相关路由配置
- 简化 TimestampPage 的导入和状态管理逻辑
- 替换自定义日期格式化函数为工具类方法
- 重构定时器逻辑,提升代码可读性和稳定性
- 更新按钮交互功能,增强无障碍访问支持
- 优化输入控件结构和事件处理方式
- 改进时间戳转换逻辑并增加空值校验
- 调整DOM结构和样式类名以提高一致性
2025-12-19 00:40:02 +08:00
雨霖铃 e19dff6f79 feat(extension): 添加时间戳转换和复制功能
- 新增时间戳与日期互相转换功能
- 实现带反馈的文本复制组件
- 添加时间戳实时更新与控制按钮
- 配置ESLint规则支持React和Web Extensions
- 注册background script处理复制权限
- 更新manifest.json配置文件
- 优化CSS样式支持新功能界面
- 移除用户列表页面相关路由和导入
- 添加输入框和选择器样式
- 实现日期格式化工具函数
2025-12-18 22:54:16 +08:00
雨霖铃 519e53851d refactor(home): 重构首页模块为工厂模式
- 使用 createPageModule 工厂函数重构首页模块
- 移除手动 DOM 操作和事件绑定逻辑
- 通过控制器统一管理页面生命周期
- 集成共享模块功能演示(防抖、格式化等)
- 简化状态更新和事件处理逻辑
- 优化代码结构和可维护性
2025-12-17 22:13:01 +08:00
雨霖铃 1cc5c5f0c4 chore(project): 初始化 React 项目并更新依赖配置
- 将项目从 Chrome 扩展模板迁移到 Create React App
- 移除原有的 Chrome 扩展相关文件(background.js、content.js、manifest.json)
- 删除旧的开发指南 DEVELOPMENT.md
- 更新 package-lock.json 以支持 React 开发环境
- 替换 .gitignore 文件内容为标准 React 项目忽略规则
- 移除所有原有的图标资源文件(images/*.png)
- 更新 README.md 为 Create React App 标准文档
- 添加 React、React DOM、React Router 等核心依赖
- 配置测试库(@testing-library)及相关工具
- 设置 web-vitals 性能监控依赖
2025-12-16 23:42:59 +08:00
雨霖铃 52f6dcce3d feat(router): 增强路由组件加载逻辑
1. 修改卸载timer、event、script的逻辑
2. 修改加载script的逻辑
2025-12-14 09:11:52 +08:00
雨霖铃 18ea3e0c8b feat(router): 增强路由组件加载逻辑
- 添加组件加载成功日志记录
- 在加载脚本前输出调试信息
- 更新模块导入后的日志格式
- 优化组件实例获取流程
- 改进依赖脚本加载顺序
- 增加对模块类型判断的日志输出
2025-12-13 22:12:54 +08:00
雨霖铃 8cb9580860 refactor(router): 优化路由重定向配置与组件初始化逻辑
- 设置默认重定向路由为 '/'
- 修复路由实例化后未正确调用 init 方法的问题
- 移除不必要的控制台日志输出
- 调整路由守卫中 next 回调的参数传递方式
- 导出 Router 类时增加空格以符合代码风格
- 更新时间戳组件导入方式并移除冗余初始化调用
- 为定时器管理器添加清理功能的日志提示
- 从 BaseComponent 中移除调试日志并修复定时器返回值处理
2025-12-13 21:52:52 +08:00
雨霖铃 aaefc9c3fc feat(sidepanel): 实现基于组件的架构并优化路由管理
- 引入 BaseComponent 基类统一管理事件、定时器和脚本加载
- 重构 timestamp 模块为继承自 BaseComponent 的类组件
- 新增 ScriptManager 工具类用于动态加载和卸载脚本
- 修改路由配置支持按需加载页面对应的 JS 模块
- 路由切换时自动销毁旧组件实例以防止内存泄漏
- 更新文件引用路径适配新的目录结构
- 修复 rollup 配置中的语法问题及端口配置
- 移除无用的日志输出和冗余代码提升性能
2025-12-12 00:01:00 +08:00
雨霖铃 4a76561a85 feat(timestamp): 重构时间戳模块并优化时区处理
- 将时区列表提取到独立常量文件 `timezone.js` 中,便于维护和扩展
- 重构时间戳显示逻辑,将初始化功能从事件处理器中分离
- 引入 timerManager、eventManager 和 scriptManager 进行资源统一管理
- 更新 DOM 操作方式,使用 window.eventManager 替代直接 addEventListener
- 改进时间戳更新函数,支持传入元素引用以提高灵活性
- 在路由加载脚本时增加调试日志,方便追踪组件加载过程
- 移除冗余的 DOM 工具函数(如 getElementById),统一使用原生方法
- 修复计时器清理问题,确保通过 timerManager 正确清除定时任务
- 初始化时间戳和日期输入框默认值,提升用户体验
2025-12-10 22:16:10 +08:00
雨霖铃 28fc3b409f feat(router): 重构路由模块并优化页面加载逻辑
- 引入 ScriptManager、TimerManager 和 EventManager 统一管理资源
- 路由配置支持默认重定向路径 '/'
- 移除旧版 scriptLoader 与 routeUtils,使用新的模块化加载机制
- 增加路由生命周期控制,自动清理定时器和事件监听器
- 改进点击代理逻辑,增强按钮激活状态切换
- 页面切换时卸载旧脚本及资源,防止内存泄漏
- 修复初始化日志冗余问题,提升代码可维护性
2025-12-09 22:22:31 +08:00
雨霖铃 2761dbe13f feat(sidepanel): 重构路由系统并优化页面加载逻辑
- 移除冗余的开发预览页面 `index.html`
- 路由配置改为声明式结构,支持自动加载 HTML 和脚本
- 新增 `data-route` 属性用于导航按钮绑定路由路径
- 实现基于事件委托的路由跳转机制
- 重写 `Router.js` 核心逻辑,支持异步加载页面模块
- 改进 `domUtils.js` 中 `addClass` 和 `removeClass` 方法兼容性与健壮性
- 新增 `scriptLoader.js` 工具模块用于动态加载和清理页面脚本
- 时间戳页面初始化方式调整为 IIFE 并挂载至全局作用域
2025-11-30 23:34:40 +08:00
雨霖铃 884fc09750 ```
feat(router): 引入路由系统并重构页面结构

- 为项目引入基于 hash 的前端路由系统,支持多页面切换与动态加载
- 将原有单一 bundle 拆分为多个入口打包(index.js 和 timestamp.js)
- 修改 Rollup 配置以支持多输出文件及插件共享
- 更新 HTML 结构以适配路由容器和页面占位元素
- 调整 CSS 样式,移除旧的页面切换动画,交由路由控制
- 优化 domUtils 工具函数,并迁移部分样式处理逻辑
- 在 timestamp.js 中封装初始化函数以便路由调用
- todolist 页面模板新增,用于后续功能开发
```
2025-11-27 23:04:24 +08:00
雨霖铃 ed6053473d https://github.com/biaochenxuying/route添加实现路由的代码-L1 2025-11-25 01:07:46 +08:00
雨霖铃 a4c331bd8b 优化复制时间戳现实 2025-11-19 22:30:53 +08:00
雨霖铃 1e83988b72 ```
feat(sidepanel): 优化时间戳工具界面与交互功能

- 调整 CSS 样式,增加 #current-timestamp 区块样式定义,统一阴影写法空格格式
- 重构 HTML 结构,将当前时间戳相关元素包裹进 div 容器中,并调整按钮布局
- 限制时区列表为常用时区,提升选择效率并确保兼容性
- 改进切换单位和复制按钮的反馈效果,添加动画提示增强用户体验
- 精简 updateTimestamp 函数逻辑,移除冗余代码并提高可维护性
```
2025-11-19 22:18:48 +08:00
雨霖铃 951a0579ce fix(timestamp): 优化日期转时间戳函数支持时区处理
移除调试日志,增强 `convertDateToTimestamp` 函数的健壮性:
- 支持输入 Date 对象和字符串
- 增加对时区的支持,通过计算目标时区偏移量得到正确时间戳
- 添加错误捕获与返回明确的错误信息
- 提取获取时区偏移量逻辑为独立函数 `getTimeZoneOffset`
2025-11-18 23:38:50 +08:00
雨霖铃 bc1943dc16 ```
feat(dev): 添加开发服务器与热重载支持

- 新增 `npm run dev` 命令,启动带热重载的开发服务器
- 集成 rollup watch 模式与 LiveReload 实现自动刷新
- 添加开发指南 DEVELOPMENT.md,说明项目结构、预览方式及构建流程
- 新增开发预览页面 index.html,用于嵌入展示 sidepanel 内容
- 更新 package.json 和 package-lock.json,引入相关开发依赖
```
2025-11-18 00:26:41 +08:00
雨霖铃 8b608341e7 ```
feat(sidepanel): 重构时间戳转换功能并优化用户界面

- 将代码模块化,拆分为 eventHandlers、domUtils 和 timestampUtils 等文件
- 使用 Rollup 配置文件替代内联构建命令,提升可维护性
- 引入页面切换动画与交互反馈动画,增强用户体验
- 更新 HTML 元素 ID 命名以提高语义化和一致性
- 改进 CSS 样式,增加悬停效果、过渡动画和响应式支持
- package.json 中添加 type: module 并升级版本至 1.1
```
2025-11-17 00:25:03 +08:00
雨霖铃 3b9e362bc9 ```
feat(sidepanel): 优化时间戳工具页面布局与响应式样式

新增 datetime-box 和 input-group 容器类,用于优化时间戳转换和日期时间转换功能的
界面结构。调整 input-text、select-box 和 convert-button 的宽度及响应式行为,使其在
移动端设备上能够自适应显示。同时更新 HTML 结构以匹配新的 CSS 类名和布局方式,提升
用户体验与界面可读性。
```
2025-11-14 00:20:35 +08:00
雨霖铃 43ee47751b feat(sidepanel): 添加时间戳转换功能并优化默认状态
新增时间戳与日期时间相互转换的功能,包括输入框、选择器和转换按钮。
同时将侧边栏默认状态设为关闭,并完善相关样式和逻辑。
此外,增强复制功能的上下文检查以提升稳定性。
2025-11-12 22:31:00 +08:00
雨霖铃 a42f1f2cce feat(background): 添加消息监听以支持复制文本功能
新增 `chrome.runtime.onMessage` 监听器,用于接收来自 popup 或 sidepanel 的
`copyText` 消息,并将该消息转发至当前活动标签页的内容脚本,实现跨组件通信
与剪贴板写入功能。同时更新 manifest.json 权限声明,增加 clipboardWrite 和
clipboardRead 权限以支持 navigator.clipboard API 的调用。

此外,简化了 sidepanel 的 HTML 结构并增强时间戳工具功能,包括动态切换时间戳
单位、实时刷新与复制到剪贴板等交互逻辑。
2025-11-11 23:56:53 +08:00
雨霖铃 3ab976b930 feat(extension): 初始化Chrome扩展项目结构与基础功能 2025-11-11 01:07:43 +08:00
200 changed files with 6239 additions and 25456 deletions
+5 -10
View File
@@ -1,19 +1,14 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(npm install:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npx vitest:*)",
"Bash(git rm:*)",
"Bash(git stash:*)",
"Bash(git reset:*)",
"Bash(git checkout:*)",
"Bash(npx tsc:*)",
"Bash(npx eslint:*)",
"Bash(npm run:*)",
"Bash(npm test:*)",
"Bash(pnpm typecheck *)",
"Bash(pnpm test *)"
"Bash(git show-ref:*)",
"Bash(git checkout:*)",
"mcp__plugin_playwright_playwright__browser_navigate"
]
}
}
File diff suppressed because it is too large Load Diff
-141
View File
@@ -1,141 +0,0 @@
# 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 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范)
```
pages/FeatureName/
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
└── constants.ts # 常量定义(可选)
```
- 页面组件调用 `useI18n('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),无需手动配置。
### 代码风格
- 禁止使用 `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`
-151
View File
@@ -1,151 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
setup:
name: Prepare Dependencies
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-info.outputs.key }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Cache Node Modules
id: cache-nodemodules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-v22-
- name: Install dependencies
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
run: npm ci
- name: Output Cache Key
id: cache-info
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
lint:
name: Lint
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Run ESLint
run: npm run lint
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Run TypeScript type check
run: npm run typecheck
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Run tests
run: npm run test
build:
name: Build (${{ matrix.browser }})
runs-on: ubuntu-latest
needs: [ lint, typecheck, test ]
strategy:
matrix:
browser: [ chrome, firefox ]
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Restore Node Modules Instantantly
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
- name: Generate WXT types
run: npx wxt prepare
- name: Build Extension (${{ matrix.browser }})
run: |
if npm run | grep -q "build:${{ matrix.browser }}"; then
npm run build:${{ matrix.browser }}
else
npm run build -- --browser ${{ matrix.browser }}
fi
+31
View File
@@ -0,0 +1,31 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm install
- run: npm run build --if-present
# - run: npm test
-162
View File
@@ -1,162 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
# ── Phase 1: 依赖准备 ───────────────────
setup:
name: Prepare Dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/cache@v4
id: cache-nodemodules
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-release-v22-
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
run: npm ci
# ── Phase 2: 质量检查 ──────────────────
lint:
name: Lint
runs-on: ubuntu-latest
needs: setup
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- run: npm run lint
typecheck:
name: TypeScript Check
runs-on: ubuntu-latest
needs: setup
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- run: npx wxt prepare
- run: npm run typecheck
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: setup
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- run: npx wxt prepare
- run: npm run test
# ── Phase 3: 打包 ───────────────────────
build-extension:
name: Package Extension
runs-on: ubuntu-latest
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
- 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
run: |
npm run zip
npm run zip:firefox
# 🔍 调试:确认 zip 文件位置
- 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
uses: actions/upload-artifact@v4
with:
name: extension-zips
path: release-archives/
if-no-files-found: error
retention-days: 7
# ── Phase 4: 发布 ───────────────────────
release:
name: Create GitHub Release
runs-on: ubuntu-latest
needs: build-extension
steps:
- uses: actions/checkout@v4
- name: Download Extension Artifacts
uses: actions/download-artifact@v4
with:
name: extension-zips
path: release-artifacts
# 🔍 调试:确认下载成功
- name: Verify downloaded artifacts
run: |
echo "=== release-artifacts/ ==="
ls -la release-artifacts/
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "v${{ steps.version.outputs.version }}"
tag_name: ${{ github.ref_name }}
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
files: |
release-artifacts/*.zip
+1 -8
View File
@@ -13,6 +13,7 @@ stats.html
stats-*.json
.wxt
.vitest
.claude
# Editor directories and files
.vscode/*
@@ -24,11 +25,3 @@ stats-*.json
*.njsproj
*.sln
*.sw?
.trae/*
.workbuddy/*
.qoder/*
dev/*
docs/*
!docs/VISUAL_STYLE_GUIDE.md
-4
View File
@@ -1,5 +1 @@
# 后台运行类型检查,结果输出到 stderr 但不阻塞提交
npx tsc --noEmit &
# 前台运行 lint-staged(自动修复 + 格式化)
npx lint-staged
-6
View File
@@ -1,6 +0,0 @@
# pre-push: 严格检查,阻塞有问题的代码推送到远程
# 类型检查
npx tsc --noEmit
# ESLint 严格检查(不允许 warning
npx eslint . --max-warnings=0
+114 -124
View File
@@ -1,153 +1,143 @@
# AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
## 核心命令
### 开发相关
- `npm run dev` - 启动 Chrome 浏览器的开发模式
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展
- `npm run zip:firefox` - 打包 Firefox 扩展
- `npm run compile` - TypeScript 类型检查(不生成文件)
- `npm run lint` - 运行 ESLint 检查
### 测试相关
- `npm run test` - 运行所有测试(单次执行)
- `npm run test:watch` - 运行测试并监听文件变化
- `npm run test:coverage` - 运行测试并生成覆盖率报告
**运行单个测试文件:**
```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 # tsc --noEmit
npm run test # vitest run (单次执行)
npm run test:watch # vitest 监视模式
npm run test:coverage # 带覆盖率的测试
npx vitest run components/__tests__/CopyButton.test.tsx
```
运行单个测试: `npx vitest run path/to/file.test.ts`
**测试技术栈:**
## 验证流程
- Vitest - 测试框架
- @testing-library/react - React 组件测试
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent
- jsdom - 浏览器环境模拟
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败):
### 依赖与准备
1. `setup`(安装依赖、`wxt prepare`
2. 并行运行 `lint``typecheck``test`(三者全部通过才继续)
3. `build`(仅当步骤 2 全部成功时执行)
- `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
Pre-commit hook`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交):
## 项目架构
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 (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI (MUI)
- **状态管理**: React Hooks
- **路由**: React Router DOM
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
- 生产构建输出到 `.output/` 目录。
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
## 项目结构
### 目录结构
```
src/ # 源代码根目录
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils
public/ # 静态资源(图标、_locales 等)
├── components/ # 可复用 UI 组件
├── CopyButton.tsx # 复制按钮组件
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
├── Navbar.tsx # 导航栏组件
│ ├── RoutePersistence.tsx # 路由持久化组件
│ ├── TimestampExecution.tsx # 时间戳执行组件
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(主进程)
│ ├── content.ts # 内容脚本(注入到页面)
└── popup/ # 扩展弹窗界面
│ ├── App.tsx # 弹窗主应用
│ ├── main.tsx # 弹窗入口
│ └── pages/ # 弹窗页面
│ ├── TestPage.tsx # 测试页面
│ └── TimestampPage.tsx # 时间戳工具页面
├── utils/ # 工具函数
│ ├── chromeStorage.ts # Chrome 存储工具
│ ├── dayjs.ts # 日期处理工具
│ └── messages.tsx # 消息通信工具
├── types/ # 类型定义
│ └── storage.d.ts # 存储相关类型
```
### 页面组件模式
### 核心功能实现
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
#### 1. 时间戳转换工具
```
src/pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选,≥3 个常量时创建)
└── __tests__/
└── index.test.tsx
- 位置: `components/` 目录下的时间戳相关组件
- 依赖: dayjs 库进行日期处理
- 功能: 支持日期与时间戳的双向转换,支持多种格式
#### 2. 通信系统
- 位置: `utils/messages.tsx`
- 机制: 使用 `@webext-core/messaging` 库实现
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
#### 3. 数据存储
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
### 关键配置文件
#### wxt.config.ts
- 配置 WXT 框架参数
- 启用 React 模块
- 配置浏览器扩展权限
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
#### manifest 权限
```typescript
permissions: [
'storage', // 存储权限
'unlimitedStorage', // 无限制存储
'clipboardWrite', // 剪贴板写入
'activeTab', // 当前标签页
'scripting', // 脚本注入
'tabs', // 标签页管理
'debugger', // 调试器
],
host_permissions: ['<all_urls>'] // 访问所有网站
```
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
- 子组件可以独立调用 `useI18n` 等全局 Hook
-`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
- 复杂页面可增加 `contexts/``hooks/``components/` 子目录
## 开发注意事项
## 关键架构决策
### 扩展入口点
**路由**: 不使用 React Router。通过 `src/config/features.tsx` `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 做快照以消除首屏闪烁。
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
### 浏览器兼容性
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
### 代码质量
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。
## 测试环境
- 环境: jsdom
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
- Setup 文件: `vitest.setup.ts` 自动 mock:
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
- `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译)
- `window.matchMedia`
- 测试文件命名: `__tests__/*.test.{ts,tsx}``*.test.{ts,tsx}`
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## i18n (chrome.i18n)
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
- **翻译文件**: `public/_locales/{zh,en}/messages.json`Chrome 扩展标准格式)
- **默认语言**: `zh`(在 `wxt.config.ts``manifest.default_locale` 中配置)
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'`
- **翻译键格式**:
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title`
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search`
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key``features_key`
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })`
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4.`public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译
5. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
6. 添加对应的单元测试
## 代码规范
- 禁止使用 `any` (测试文件除外)
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className``cn()` 工具)
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
- 图标: 使用 `lucide-react` 图标库
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
- ESLint 使用 `typescript-eslint``projectService: true`(无需手动维护 project 路径)
## 关键外部库(非显而易见的)
- `@webext-core/messaging` — 扩展消息通信
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
- `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar
- 使用 ESLint 进行代码检查
- Husky 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范
+171 -1
View File
@@ -1 +1,171 @@
@AGENT.md
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
## 核心命令
### 开发相关
- `npm run dev` - 启动 Chrome 浏览器的开发模式
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展
- `npm run zip:firefox` - 打包 Firefox 扩展
- `npm run compile` - TypeScript 类型检查(不生成文件)
- `npm run lint` - 运行 ESLint 检查
### 依赖与准备
- `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
### CI/CD
- GitHub Actions 配置: `.github/workflows/node.js.yml`
- 在 main 分支推送或 PR 时触发
- 使用 Node.js 22.x 运行 build
- 测试命令当前被注释(项目暂无测试)
## 项目架构
### 技术栈
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI (MUI)
- **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging
### 目录结构
```
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
│ ├── popup/ # 扩展弹窗界面
│ │ ├── App.tsx # 弹窗主应用
│ │ ├── main.tsx # 弹窗入口
│ │ ├── index.html # 弹窗 HTML
│ │ └── pages/ # 弹窗页面
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
│ └── options/ # 选项页面(当前为静态 HTML)
│ └── index.html # 选项页 HTML
├── utils/ # 工具函数
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging
├── types/ # 类型定义
│ └── storage.d.ts # StorageSchema 类型定义
├── constants/ # 常量定义(当前为空)
└── public/ # 静态资源
```
### 核心功能
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
- 实时显示当前时间戳(毫秒/秒可切换)
- 时间戳 → 日期时间转换
- 日期时间 → 时间戳转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制功能
- 输入验证和错误提示
### 扩展入口点
- **后台脚本** (`entrypoints/background.ts`):
- 监听扩展安装/更新事件
- 自动向所有有效标签页注入内容脚本
- 过滤受限协议(chrome://, about:// 等)
- **内容脚本** (`entrypoints/content.ts`):
- 匹配所有 URL (`<all_urls>`)
- 在文档开始时运行
- 当前为占位符,无实际逻辑
- **弹窗** (`entrypoints/popup/`):
- 主入口显示 TimestampPage
- 提供时间戳转换的完整功能
- **选项页** (`entrypoints/options/`):
- 当前为静态 HTML 页面
- 可扩展为设置界面
### 数据存储
使用 Chrome Storage API 进行持久化存储:
- 类型安全的封装 (`utils/chromeStorage.ts`)
- 基于接口定义的 Schema (`types/storage.d.ts`)
- 当前支持的存储键:
- `app/lastRoute`: 上次访问的路由
- `app/theme`: 主题设置
### 消息通信
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
- 定义在 `utils/messages.tsx`
- 当前 ProtocolMap 为空(预留接口)
### 关键配置文件
#### wxt.config.ts
- 启用 React 模块 (`@wxt-dev/module-react`)
- 配置 manifest 权限和 host_permissions
- 使用 Terser 压缩(强制 ASCII 编码)
- 配置图标和选项页
#### manifest 权限
```typescript
permissions: [
'storage', // Chrome Storage
'unlimitedStorage', // 无限制存储
'clipboardWrite', // 剪贴板写入(复制功能)
'activeTab', // 当前标签页访问
'scripting', // 脚本注入
'tabs', // 标签页管理
'debugger', // 调试器权限
],
host_permissions: ['<all_urls>'] // 访问所有网站
```
## 开发注意事项
### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
### 代码质量
- 使用 ESLint 进行代码检查(零警告)
- Husky 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier
- Prettier 用于代码格式化
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
### TypeScript 配置
- 严格模式开启(`strict: true`
- `noImplicitAny` 设置为 `false`(允许隐式 any
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`
- 模块解析模式:Bundler
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
### 项目历史
近期重构(根据 git 历史):
- 移除了录制回放功能
- 移除了测试页面
- 精简为单页面时间戳工具
- 将 storage 工具类重命名为 storageUtil
+72
View File
@@ -0,0 +1,72 @@
# Testing Tools 项目指南
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
## 1. 项目概览
- **名称**: Testing Tools
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
## 2. 项目结构
```text
├── .github/ # CI/CD 工作流
├── .husky/ # Git Hooks (pre-commit linting)
├── assets/ # 静态资源 (SVG 等)
├── components/ # 复用 UI 组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台 Service Worker 逻辑
│ ├── content.ts # 内容脚本注入逻辑
│ ├── popup/ # 扩展弹出层 (主要功能区)
│ └── options/ # 扩展选项页面
├── types/ # 全局 TypeScript 类型声明
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
├── wxt.config.ts # WXT 框架与 Manifest 配置
└── package.json # 依赖管理与脚本
```
## 3. 开发规范与风格约定
### 3.1 UI 设计语言
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
- **MUI 定制**:
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
- **布局**: 优先使用 `Stack``Box` 进行布局,确保自上而下的操作流顺畅。
### 3.2 技术选型惯例
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
- **状态管理**: 优先使用 React 原生 `useState``useMemo`
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
## 4. 关键指令
### 4.1 开发与调试
- `npm run dev`: 启动 Chrome 扩展开发模式。
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
### 4.2 构建与检查
- `npm run build`: 构建生产版本。
- `npm run compile`: TypeScript 类型检查。
- `npm run lint`: ESLint 代码风格检查。
## 5. 权限与清单 (Manifest)
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
## 6. 维护者提示
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Testing Tools
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+84 -116
View File
@@ -1,153 +1,121 @@
# Testing Tools Browser Extension
这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具.
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具
## 项目概述
**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持.
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
## 功能特性
### 🚀 Dashboard 首页
### 时间戳转换工具
- **工具导航**: 快速访问所有可用工具.
- **个性化定制**: 支持自定义工具的排序和可见性.
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
### ⏰ 时间戳转换工具
- **实时显示**: 毫秒级精度显示当前系统时间.
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
- **快捷操作**: 一键复制转换结果,支持多种格式.
### 🧹 存储清理工具
- **智能识别**: 自动检测并显示当前活动标签页的域名.
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
- **细粒度控制**: 可根据需要选择特定的清理项.
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
### 📝 文本统计工具
- **实时分析**: 键入即统计,无需额外操作.
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
- **性能优化**: 采用高性能分词算法,支持大文本处理.
### 🔑 JWT 解析工具
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
### 🖱️ 右键恢复工具
- **当前页面检测**: 自动识别当前活动标签页的域名.
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
### 🖼️ 二维码工具
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
### 🔄 JSON 工具
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
### 🧰 Base64 转换器
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
- **文件转换**: 支持文件与 Base64 字符串互转.
- **图像预览**: 支持图片 Base64 编码与实时预览.
- 实时显示当前时间戳(毫秒/秒可切换)
- 日期与时间戳之间的双向转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制转换结果
- 输入验证和错误提示
## 技术栈
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **框架**: WXT (Web Extension Toolkit)
- **前端**: React 19 + TypeScript
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **国际化**: i18next + react-i18next
- **UI **: Material UI
- **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
- **测试**: Vitest + Testing Library
## 项目结构
```text
├── src/ # 源代码根目录
│ ├── components/ # 可复用 React 组件
│ ├── config/ # 应用配置
│ └── features.tsx # 功能定义与路由映射
── entrypoints/ # 扩展程序入口点
├── popup/ # 点击图标弹出的主界面
│ │ ├── sidepanel/ # 浏览器侧边栏集成
│ │ ├── background.ts # 后台 Service Worker
│ │ └── content.ts # 网页注入脚本
├── pages/ # 各功能模块的页面组件
│ ├── providers/ # 全局状态提供者 (Router, Theme 等)
│ ├── hooks/ # 自定义 React Hooks
│ ├── utils/ # 工具函数与服务抽象
│ ├── types/ # TypeScript 类型声明
│ └── lib/ # 通用工具函数 (cn, utils 等)
├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理
```
├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
│ ├── options/ # 选项页面
├── background.ts # 后台脚本
── content.ts # 内容脚本
├── utils/ # 工具函数(存储、日期处理、消息通信)
├── types/ # TypeScript 类型定义
├── public/ # 静态资源
├── wxt.config.ts # WXT 配置文件
├── package.json # 项目依赖和脚本
└── README.md # 项目说明文档
```
## 开发与部署
## 开发环境要求
### 开发环境要求
- Node.js >= 18
- npm 或 yarn
- Node.js >= 18.x
- npm 或 pnpm
## 安装与运行
### 常用命令
### 1. 安装依赖
| 命令 | 说明 |
| ----------------------- | --------------------------------- |
| `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 typecheck` | 执行 TypeScript 类型检查 |
| `npm run lint` | 执行 ESLint 代码规范检查 |
| `npm run test` | 运行单元测试 |
| `npm run test:coverage` | 生成测试覆盖率报告 |
```bash
npm install
```
运行单个测试: `npx vitest run path/to/file.test.ts`
### 2. 开发模式
### 自动化流程
```bash
# Chrome 浏览器
npm run dev
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
# Firefox 浏览器
npm run dev:firefox
```
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
### 3. 构建生产版本
```bash
# Chrome 浏览器
npm run build
# Firefox 浏览器
npm run build:firefox
```
### 4. 打包分发
```bash
# Chrome 浏览器
npm run zip
# Firefox
npm run zip:firefox
```
### 5. 其他命令
```bash
npm run compile # TypeScript 类型检查
npm run lint # ESLint 代码检查
```
## 权限说明
扩展根据功能需要申请了以下权限:
扩展请求以下权限:
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
- `scripting`: 在网页中执行清理和右键恢复脚本.
- `cookies`: 管理和清理网站 Cookie.
- `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能.
- `contextMenus`: 注册右键菜单,支持快捷操作.
- `alarms`: 管理后台定时任务(如清理后自动刷新).
- `storage` `unlimitedStorage` - 本地数据存储
- `clipboardWrite` - 剪贴板写入(复制功能)
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
- `debugger` - 调试器权限
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
## 浏览器支持
## 主要依赖
- Chrome (及其它 Chromium 内核浏览器)
- Firefox
- `react`, `react-dom` - 前端框架
- `@mui/material` - UI 组件库
- `dayjs` - 日期处理
- `@webext-core/messaging` - 扩展消息通信
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
基于 [MIT License](LICENSE) 开源.
此项目为私有项目 (private: true),仅供内部使用。

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

-20
View File
@@ -1,20 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
-871
View File
@@ -1,871 +0,0 @@
# Testing Tools — 视觉规范文档
> **版本**: 1.0.0
> **日期**: 2026-05-29
> **适用范围**: 所有新页面、新组件、UI 修改
> **设计系统**: 基于 [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS
---
## 目录
1. [设计原则](#1-设计原则)
2. [色彩系统](#2-色彩系统)
3. [排版规范](#3-排版规范)
4. [间距与布局](#4-间距与布局)
5. [圆角与阴影](#5-圆角与阴影)
6. [组件规范](#6-组件规范)
7. [交互与动效](#7-交互与动效)
8. [暗色模式](#8-暗色模式)
9. [工具色彩标识](#9-工具色彩标识)
10. [代码规范](#10-代码规范)
11. [反模式清单](#11-反模式清单)
---
## 1. 设计原则
### 1.1 核心定位
Testing Tools 是一款**浏览器扩展开发者工具集**,视觉风格遵循:
- **专业克制** — 低饱和度色彩,避免视觉噪音
- **信息密度优先** — 紧凑布局,在 400×600px 的 popup 空间内高效展示
- **开发者友好** — 等宽字体用于代码/数据,清晰的信息层级
- **一致性至上** — 所有页面、组件遵循同一套视觉语言
### 1.2 设计关键词
```
简洁 · 现代 · 功能导向 · 低对比度 · 微圆角 · 微妙阴影
```
### 1.3 与 shadcn/ui 的关系
本项目以 shadcn/ui 为底座,所有基础组件(Button、Input、Select 等)均来自或对齐 shadcn/ui 的默认样式。业务组件在此基础上扩展,**不得破坏底层设计语言的统一性**。
---
## 2. 色彩系统
### 2.1 CSS 变量定义
所有色彩通过 CSS 自定义属性(HSL 格式)管理,定义于 `src/index.css`
#### 亮色模式 (`:root`)
| 变量名 | HSL 值 | 用途 | 近似色 |
| -------------------------- | ------------------- | ------------- | --------- |
| `--background` | `0 0% 100%` | 页面背景 | `#ffffff` |
| `--foreground` | `222.2 84% 4.9%` | 主文字 | `#020617` |
| `--card` | `0 0% 100%` | 卡片背景 | `#ffffff` |
| `--card-foreground` | `222.2 84% 4.9%` | 卡片文字 | `#020617` |
| `--popover` | `0 0% 100%` | 浮层背景 | `#ffffff` |
| `--popover-foreground` | `222.2 84% 4.9%` | 浮层文字 | `#020617` |
| `--primary` | `222.2 47.4% 11.2%` | 主按钮/强调 | `#0f172a` |
| `--primary-foreground` | `210 40% 98%` | 主按钮文字 | `#f8fafc` |
| `--secondary` | `210 40% 96.1%` | 次级背景 | `#f1f5f9` |
| `--secondary-foreground` | `222.2 47.4% 11.2%` | 次级文字 | `#0f172a` |
| `--muted` | `210 40% 96.1%` | 静音/禁用背景 | `#f1f5f9` |
| `--muted-foreground` | `215.4 16.3% 46.9%` | 次要文字 | `#64748b` |
| `--accent` | `210 40% 96.1%` | 悬停高亮 | `#f1f5f9` |
| `--accent-foreground` | `222.2 47.4% 11.2%` | 悬停文字 | `#0f172a` |
| `--destructive` | `0 84.2% 60.2%` | 错误/删除 | `#ef4444` |
| `--destructive-foreground` | `210 40% 98%` | 错误文字 | `#f8fafc` |
| `--border` | `214.3 31.8% 91.4%` | 边框 | `#e2e8f0` |
| `--input` | `214.3 31.8% 91.4%` | 输入框边框 | `#e2e8f0` |
| `--ring` | `222.2 84% 4.9%` | 焦点环 | `#020617` |
| `--radius` | `0.5rem` | 全局圆角 | `8px` |
#### 暗色模式 (`.dark`)
暗色模式下所有变量自动反转,保持对比度关系:
| 变量名 | HSL 值 | 近似色 |
| ---------------------- | ------------------- | --------- |
| `--background` | `222.2 84% 4.9%` | `#020617` |
| `--foreground` | `210 40% 98%` | `#f8fafc` |
| `--primary` | `210 40% 98%` | `#f8fafc` |
| `--primary-foreground` | `222.2 47.4% 11.2%` | `#0f172a` |
| `--secondary` | `217.2 32.6% 17.5%` | `#1e293b` |
| `--muted` | `217.2 32.6% 17.5%` | `#1e293b` |
| `--border` | `217.2 32.6% 17.5%` | `#1e293b` |
### 2.2 使用规范
```tsx
// ✅ 正确:使用 CSS 变量
<div className="bg-background text-foreground border-border">
// ✅ 正确:使用语义化色彩名
<Button className="bg-primary text-primary-foreground">
<span className="text-muted-foreground">
// ❌ 错误:硬编码颜色值
<div className="bg-white text-black">
<div className="bg-[#f1f5f9]">
```
### 2.3 语义化色彩使用场景
| 色彩 | 场景 |
| -------------------------- | -------------------------------- |
| `background` | 页面根背景 |
| `foreground` | 主标题、正文 |
| `muted-foreground` | 描述文字、占位符、次级标签 |
| `border` | 卡片边框、分割线、输入框边框 |
| `card` + `card-foreground` | 卡片容器及其内容 |
| `primary` | 主按钮、选中状态、关键操作 |
| `secondary` | 次级按钮、工具栏背景、标签页背景 |
| `destructive` | 错误提示、删除操作、验证失败 |
| `accent` | 悬停背景、下拉选中项 |
---
## 3. 排版规范
### 3.1 字体栈
项目使用系统默认字体栈(Tailwind 默认),**不引入自定义字体**。
```css
/* Tailwind 默认 sans-serif */
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
sans-serif;
/* 等宽字体用于代码/数据 */
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
```
### 3.2 字号层级
| 层级 | 类名 | 大小 | 字重 | 用途 |
| --------- | ------------------------------------------------ | ---- | ------- | ---------------------- |
| 页面标题 | `text-base font-bold` | 16px | 700 | 页面主标题(极少使用) |
| 卡片标题 | `text-sm font-bold tracking-tight` | 14px | 700 | 卡片/区块标题 |
| 正文 | `text-sm` | 14px | 400 | 普通正文 |
| 次级文字 | `text-xs` | 12px | 400/500 | 描述、标签 |
| 微标签 | `text-[10px] font-bold uppercase tracking-wider` | 10px | 700 | 区域标签、分类标题 |
| 数据/代码 | `font-mono text-sm` | 14px | 400 | 时间戳、JSON、代码 |
### 3.3 排版模式
```tsx
// 区域标签(Section Label)— 最常用
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
</span>
// 卡片标题
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
</h4>
// 描述文字
<p className="text-[11px] font-medium text-muted-foreground/90 leading-normal">
</p>
// 数据展示
<span className="font-mono font-bold text-foreground text-sm tracking-tight tabular-nums">
1716950400000
</span>
```
### 3.4 行高与字间距
| 属性 | 值 | 场景 |
| ----------------- | -------- | ------------------ |
| `leading-none` | 1 | 单行数据、紧凑布局 |
| `leading-snug` | 1.375 | 标题、短文本 |
| `leading-relaxed` | 1.625 | 长文本、代码块 |
| `tracking-tight` | -0.025em | 标题、数据 |
| `tracking-wider` | 0.05em | 大写标签 |
---
## 4. 间距与布局
### 4.1 容器尺寸
```tsx
// Popup 模式(默认)
<div className="w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px]">
// Tab 模式(全屏自适应)
<div className="sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
```
### 4.2 间距节奏
| Token | 值 | 使用场景 |
| --------------- | ----------- | ------------------ |
| `p-3` / `p-3.5` | 12px / 14px | 页面内边距(紧凑) |
| `p-4` | 16px | 标准页面内边距 |
| `p-5` | 20px | 卡片内部填充 |
| `gap-2` | 8px | 紧凑元素间距 |
| `gap-3` | 12px | 标准元素间距 |
| `gap-4` | 16px | 区块间距 |
| `gap-6` | 24px | 大区块间距 |
### 4.3 布局模式
#### 页面布局
```tsx
// 标准页面结构
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">{/* 页面内容 */}</div>
```
#### 卡片布局
```tsx
// 标准卡片
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
{/* 卡片内容 */}
</div>
// 可聚焦卡片(含焦点环)
<div className="border border-border rounded-xl bg-card ... focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
```
#### 双栏网格
```tsx
// 响应式双栏
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
```
#### 工具卡片网格(Dashboard
```tsx
// Dashboard 紧凑网格
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
```
---
## 5. 圆角与阴影
### 5.1 圆角体系
| Token | 值 | 使用元素 |
| -------------- | ------ | ---------------------- |
| `rounded-sm` | 2px | Checkbox、小标签 |
| `rounded-md` | 6px | 按钮、输入框、Select |
| `rounded-lg` | 8px | 搜索框、小卡片 |
| `rounded-xl` | 12px | 大卡片、面板、图标容器 |
| `rounded-full` | 9999px | 标签、Avatar |
### 5.2 阴影体系
| 级别 | 类名 | 用途 |
| ---- | --------------------- | ---------------------- |
| 无 | — | 静态元素 |
| 低 | `shadow-sm` | 卡片、输入框、按钮 |
| 中 | `shadow-lg` | 下拉菜单、浮层、Dialog |
| 动态 | 自定义 `shadow-[...]` | 卡片悬停时的彩色阴影 |
### 5.3 彩色阴影规范(工具卡片专用)
```tsx
// 工具卡片悬停阴影 — 必须使用 rgba 格式配合 CSS 变量
className="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)]"
```
---
## 6. 组件规范
### 6.1 Button
来源:`src/components/ui/button.tsx`
#### 变体
| 变体 | 类名 | 场景 |
| ------------- | -------------------------------------------- | ------------------ |
| `default` | `bg-primary text-primary-foreground` | 主操作 |
| `destructive` | `bg-destructive text-destructive-foreground` | 删除、危险操作 |
| `outline` | `border border-input bg-background` | 次级操作、取消 |
| `secondary` | `bg-secondary text-secondary-foreground` | 次要操作 |
| `ghost` | 仅悬停背景 | 图标按钮、低优先级 |
| `link` | 下划线文字 | 跳转链接 |
#### 尺寸
| 尺寸 | 高度 | 内边距 | 场景 |
| --------- | ------- | ----------- | -------- |
| `default` | 40px | `px-4 py-2` | 标准按钮 |
| `sm` | 36px | `px-3` | 紧凑按钮 |
| `lg` | 44px | `px-8` | 突出按钮 |
| `icon` | 40×40px | — | 图标按钮 |
#### 使用示例
```tsx
// 主操作
<Button></Button>
// 图标按钮
<Button variant="ghost" size="icon">
<Copy className="h-4 w-4" />
</Button>
// 危险操作
<Button variant="destructive" size="sm"></Button>
```
### 6.2 Input
来源:`src/components/ui/input.tsx`
```tsx
// 标准输入框
<Input
className="font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background"
/>
// 错误状态
<Input
className="border-destructive focus-visible:ring-destructive"
/>
```
**规范要点**
- 高度统一为 `h-10`40px
- 等宽字体用于数据输入
- 占位符使用 `text-muted-foreground/60`
- 错误时边框变红并调整焦点环
### 6.3 SwitchButtonGroup
来源:`src/components/ui/switch.tsx`
```tsx
// 分段控制器
<SwitchButtonGroup
value={mode}
options={[
{ value: 'ts2dt', label: '转日期' },
{ value: 'dt2ts', label: '转时间戳' },
]}
onChange={setMode}
size="small"
/>
```
**规范要点**
- 容器:`rounded-lg bg-muted p-1`
- 选中项:`bg-background text-foreground shadow-sm font-semibold`
- 未选中项:`hover:bg-background/50 hover:text-foreground/80`
- 尺寸:`small`32px)用于工具页,`medium`36px)标准
### 6.4 Card(工具卡片)
来源:`src/pages/Dashboard/ToolCard.tsx`
```tsx
// 标准工具卡片结构
<div className="group relative rounded-xl border border-border/70 bg-card p-4 h-auto flex flex-col gap-3 shadow-sm">
{/* 上半部分:图标 + 标题 + 箭头 */}
<div className="flex items-center justify-between">
<div className="flex gap-3 items-center">
{/* 图标容器 */}
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[rgba(var(--tool-color),0.08)] text-[rgb(var(--tool-color))]">
<Icon className="h-5 w-5" />
</div>
{/* 文字 */}
<div>
<h4 className="font-bold text-sm"></h4>
<p className="text-[11px] text-muted-foreground/90"></p>
</div>
</div>
<ChevronRight className="h-4 w-4" />
</div>
{/* 下半部分:预览区(可选) */}
<div className="mt-1 pt-3 border-t border-dashed border-border/80">{snapshot}</div>
</div>
```
### 6.5 TextInputArea
来源:`src/components/TextInputArea.tsx`
```tsx
// 多行文本输入区
<TextInputArea
value={input}
onChange={setInput}
placeholder="输入内容..."
showCount={true}
showClear={true}
allowCopy={true}
minRows={6}
maxRows={12}
/>
```
**规范要点**
- 外容器:`rounded-md border border-input bg-background shadow-sm`
- 焦点状态:`focus-within:ring-1 focus-within:ring-ring`
- 错误状态:`border-destructive focus-within:ring-destructive`
- 底部工具栏:`h-10 bg-muted/30 border-t border-border/50`
- 字体:`font-mono text-sm`
### 6.6 Dialog
来源:`src/components/ui/dialog.tsx`
```tsx
// 对话框内容
<DialogContent className="sm:rounded-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
{/* 内容 */}
<DialogFooter>
<Button variant="outline"></Button>
<Button></Button>
</DialogFooter>
</DialogContent>
```
### 6.7 Select
来源:`src/components/ui/select.tsx`
```tsx
<Select>
<SelectTrigger className="h-9 shadow-sm bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
<SelectItem className="text-xs font-semibold focus:bg-accent cursor-pointer"></SelectItem>
</SelectContent>
</Select>
```
### 6.8 Checkbox
来源:`src/components/ui/checkbox.tsx`
```tsx
// 标准复选框
<Checkbox className="h-4 w-4 rounded-sm border-primary data-[state=checked]:bg-primary" />
// 小型复选框(工具栏内)
<Checkbox className="h-3.5 w-3.5 rounded border-input data-[state=checked]:bg-primary shadow-sm" />
```
### 6.9 Badge
来源:`src/components/ui/badge.tsx`
| 变体 | 场景 |
| ------------- | ------------------ |
| `default` | 状态标签、分类 |
| `secondary` | 次要标签 |
| `destructive` | 错误标签 |
| `outline` | 可点击标签、筛选器 |
### 6.10 CopyButton
来源:`src/components/CopyButton.tsx`
```tsx
// 标准复制按钮
<CopyButton text={content} />
// 小型复制按钮
<CopyButton text={content} size="sm" className="h-7 w-7 rounded-md border" />
```
**规范要点**
- 默认 `variant="ghost" size="icon"`
- 复制成功后变为绿色背景 + 对勾图标
- 使用 `sonner` toast 提示复制结果
---
## 7. 交互与动效
### 7.1 过渡规范
| 属性 | 值 | 场景 |
| ------------------- | ---------- | ----------------------------- |
| `transition-colors` | 150ms ease | 色彩变化(悬停、焦点) |
| `transition-all` | 150ms ease | 综合变化(SwitchButtonGroup |
| `duration-200` | 200ms | 复制按钮状态切换 |
### 7.2 焦点状态
所有可交互元素必须有可见的焦点指示器:
```tsx
// 标准焦点环
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
// 紧凑焦点环(图标按钮)
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
// 输入框焦点
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
```
### 7.3 悬停状态
```tsx
// 按钮悬停
hover:bg-accent hover:text-accent-foreground
// 卡片悬停
hover:bg-muted/30 hover:border-[rgba(var(--tool-color),0.45)]
// 链接/文字悬停
hover:text-foreground hover:underline
```
### 7.4 动画规范
使用 `tailwindcss-animate` 提供的动画:
```tsx
// 淡入
animate-in fade-in duration-150
// 淡入 + 缩放(SwitchButtonGroup 选中项)
animate-in fade-in-50 zoom-in-95 duration-150
// 从顶部滑入(下拉菜单)
animate-in fade-in slide-in-from-top-2 duration-150
// 错误提示出现
animate-in fade-in slide-in-from-top-1 duration-150
// 骨架屏脉冲
animate-pulse
```
### 7.5 禁用状态
```tsx
// 统一禁用样式
disabled:pointer-events-none disabled:opacity-50
```
---
## 8. 暗色模式
### 8.1 实现方式
通过 `darkMode: 'class'`Tailwind 配置)+ `.dark` 类切换:
```tsx
// ThemeModeProvider 自动处理
document.documentElement.classList.toggle('dark', resolvedMode === 'dark');
```
### 8.2 暗色模式下的特殊处理
```tsx
// 彩色阴影增强(暗色模式下阴影需要更高透明度)
shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
dark:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]
// 图标容器背景增强
bg-[rgba(var(--tool-color),0.08)]
dark:bg-[rgba(var(--tool-color),0.12)]
// 成功状态文字调整
text-emerald-600 dark:text-emerald-400
```
### 8.3 暗色模式色彩映射原则
| 亮色 | 暗色 | 说明 |
| ---------------- | ---------------- | ------------------------------- |
| 纯白背景 | 深蓝黑背景 | 避免纯黑 `#000`,使用 `#020617` |
| 浅灰背景 | 深灰背景 | 保持层次关系 |
| 深文字 | 浅文字 | 反转对比度 |
| 彩色阴影低透明度 | 彩色阴影高透明度 | 暗色需要更强视觉反馈 |
---
## 9. 工具色彩标识
### 9.1 色板定义
每个工具分配一个主题色,定义于 `src/config/features.tsx`
```ts
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
primary: '13, 148, 136', // teal (#0d9488)
success: '22, 163, 74', // green (#16a34a)
warning: '217, 119, 6', // amber (#d97706)
error: '220, 38, 38', // red (#dc2626)
secondary: '147, 51, 232', // purple (#9333e8)
info: '37, 99, 235', // blue (#2563eb)
};
```
### 9.2 工具色彩分配
| 工具 | 色彩键 | 色值 |
| ----------- | ----------- | ------ |
| 时间戳转换 | `primary` | Teal |
| 存储清理 | `warning` | Amber |
| 二维码工具 | `success` | Green |
| 文本统计 | `secondary` | Purple |
| JWT 解析 | `info` | Blue |
| JSON 对比 | `primary` | Teal |
| Base64 转换 | `info` | Blue |
| 右键还原 | `success` | Green |
### 9.3 工具色彩使用规范
```tsx
// 1. 通过 style 注入 CSS 变量
<div style={{ ['--tool-color' as string]: rgbValues }}>
// 2. 图标容器背景(低透明度)
bg-[rgba(var(--tool-color),0.08)]
dark:bg-[rgba(var(--tool-color),0.12)]
// 3. 图标颜色
text-[rgb(var(--tool-color))]
// 4. 悬停边框
hover:border-[rgba(var(--tool-color),0.45)]
// 5. 悬停阴影
hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
// 6. 箭头悬停色
group-hover:text-[rgb(var(--tool-color))]
```
**注意**:工具色彩仅用于**标识和装饰**,不得用于功能性色彩(如成功/错误状态)。
---
## 10. 代码规范
### 10.1 Tailwind 类名组织顺序
使用 `cn()` 工具函数(`clsx` + `tailwind-merge`)组合类名,按以下顺序排列:
```tsx
className={cn(
// 1. 布局(display, position, flex, grid
'flex items-center justify-between',
// 2. 尺寸(width, height, padding, margin
'w-full h-10 px-4',
// 3. 外观(background, border, shadow, rounded
'rounded-md border border-input bg-background shadow-sm',
// 4. 文字(color, font, text-align
'text-sm font-medium text-foreground',
// 5. 交互(hover, focus, disabled, cursor
'hover:bg-accent focus-visible:ring-2 disabled:opacity-50',
// 6. 动画(transition, animate
'transition-colors duration-150',
// 7. 条件类
isActive && 'bg-primary text-primary-foreground',
// 8. 外部传入
className,
)}
```
### 10.2 颜色使用检查清单
- [ ] 所有颜色使用 CSS 变量(`bg-background` 而非 `bg-white`
- [ ] 边框使用 `border-border` 及其透明度变体
- [ ] 文字层级使用 `foreground``muted-foreground``muted-foreground/60`
- [ ] 错误状态使用 `destructive` 系列
- [ ] 工具色彩仅用于装饰性元素
### 10.3 组件文件组织
```
src/
├── components/ui/ # shadcn 基础组件(只读,不修改)
├── components/ # 业务组件
│ ├── CopyButton.tsx
│ ├── SwitchButtonGroup.tsx
│ ├── TextInputArea.tsx
│ └── ...
├── pages/ # 页面组件
│ ├── <ToolName>/
│ │ ├── index.tsx # 页面入口
│ │ ├── use<ToolName>.ts # 业务逻辑 Hook
│ │ └── components/ # 页面私有组件
│ └── ...
└── providers/ # Context Providers
```
### 10.4 新增页面模板
```tsx
// src/pages/NewTool/index.tsx
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
export default function Index() {
const { t } = useI18n('newTool');
return (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
{/* 页面内容 */}
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
<h4 className="font-bold text-sm tracking-tight">{t('newTool:title')}</h4>
</div>
</div>
);
}
```
---
## 11. 反模式清单
以下模式**禁止**在项目中使用:
### 11.1 色彩反模式
```tsx
// ❌ 硬编码颜色
<div className="bg-white text-black">
<div className="bg-gray-100">
<div className="text-gray-500">
// ❌ 使用非语义化 Tailwind 颜色
<div className="bg-slate-50">
<div className="text-zinc-400">
// ✅ 使用 CSS 变量
<div className="bg-background text-foreground">
<div className="bg-muted text-muted-foreground">
```
### 11.2 布局反模式
```tsx
// ❌ 固定高度导致内容截断
<div className="h-[200px]">
// ✅ 使用 min-height 或自适应
<div className="min-h-[200px]">
<div className="h-auto">
// ❌ 使用 margin 做组件间距
<div className="mb-4">
// ✅ 使用 gap
<div className="flex flex-col gap-4">
```
### 11.3 组件反模式
```tsx
// ❌ 修改 shadcn/ui 基础组件样式
// 如需修改,通过 className 覆盖或创建包装组件
// ❌ 内联样式用于颜色(工具色彩除外)
<div style={{ backgroundColor: '#f1f5f9' }}>
// ❌ 混合使用不同圆角体系
<Button className="rounded-lg"> // Button 应为 rounded-md
// ❌ 忽略焦点状态
<button className="..."> // 缺少 focus-visible 样式
```
### 11.4 暗色模式反模式
```tsx
// ❌ 仅适配部分元素
<div className="bg-white text-black dark:bg-gray-900 dark:text-white">
// ✅ 使用 CSS 变量自动适配
<div className="bg-background text-foreground">
// ❌ 暗色模式下使用不合适的透明度
<div className="bg-black/5 dark:bg-white/5"> // 对比度不足
// ✅ 使用语义化变量
<div className="bg-muted">
```
---
## 12. 附录
### 12.1 常用类名速查
```
// 页面容器
p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none
// 标准卡片
p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm
// 工具栏
flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60
// 区域标签
text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider
// 数据展示
font-mono font-semibold text-foreground text-sm
// 错误提示
text-xs font-medium text-destructive
// 空状态
p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center
// 图标按钮容器
flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background shadow-sm
// 焦点环
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
```
### 12.2 相关文件
| 文件 | 说明 |
| ------------------------------------- | ----------------------- |
| `src/index.css` | CSS 变量定义、全局样式 |
| `tailwind.config.js` | Tailwind 配置、色彩映射 |
| `src/lib/utils.ts` | `cn()` 工具函数 |
| `src/components/ui/*.tsx` | shadcn/ui 基础组件 |
| `src/config/features.tsx` | 工具配置、色彩分配 |
| `src/providers/ThemeModeProvider.tsx` | 主题模式管理 |
### 12.3 参考资源
- [shadcn/ui 文档](https://ui.shadcn.com/docs)
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
- [Radix UI 文档](https://www.radix-ui.com/)
- [Lucide Icons](https://lucide.dev/)
---
_本文档随项目迭代更新。新增组件或修改视觉风格时,请同步更新此文档。_
@@ -0,0 +1,880 @@
# Storage Cleaner Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a storage cleaner feature to the browser extension popup that allows users to clear localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, andress Workers for the current page.
**Architecture:** Add new StorageCleanerPage component with tab switching in the popup, using chrome.cookies API for cookies and script injection for other storage types. User preferences are persisted using Chrome Storage.
**Tech Stack:** React 19 + TypeScript, Material UI, Chrome Extension APIs
---
## File Structure
```
entrypoints/popup/
├── App.tsx (modify: add tab switching)
└── pages/
├── TimestampPage.tsx (no change)
└── StorageCleanerPage.tsx (create: new storage cleaner page)
types/
└── storage.d.ts (modify: add storage cleaner types)
utils/
└── storageCleaner.ts (create: storage cleaning utilities)
wxt.config.ts (modify: add cookies permission)
```
---
## Task 1: Add TypeScript Types for Storage Cleaner
**Files:**
- Modify: `types/storage.d.ts`
- [ ] **Step 1: Add storage cleaner types to StorageSchema and interfaces**
```typescript
export interface StorageSchema {
'app/lastRoute': string;
'app/theme': string;
'storageCleaner/preferences': StorageCleanerPreferences;
}
export interface StorageCleanerPreferences {
autoRefresh: boolean;
selectedTypes: StorageCleanerOptions;
}
export interface StorageCleanerOptions {
localStorage: boolean;
sessionStorage: boolean;
indexedDB: boolean;
cookies: boolean;
cacheStorage: boolean;
serviceWorkers: boolean;
}
export type StorageCleanResult =
| {
success: true;
count: number;
}
| {
success: false;
error: string;
};
export interface CleaningResult {
success: boolean;
error?: string;
localStorage?: StorageCleanResult;
sessionStorage?: StorageCleanResult;
indexedDB?: StorageCleanResult;
cookies?: StorageCleanResult;
cacheStorage?: StorageCleanResult;
serviceWorkers?: StorageCleanResult;
}
```
- [ ] **Step 2: Commit TypeScript types**
```bash
git add types/storage.d.ts
git commit -m "feat: add TypeScript types for storage cleaner"
```
---
## Task 2: Add Cookies Permission to Manifest
**Files:**
- Modify: `wxt.config.ts:10-18`
- [ ] **Step 1: Add 'cookies' permission to manifest**
```typescript
permissions: [
'storage',
'unlimitedStorage',
'clipboardWrite',
'activeTab',
'scripting',
'tabs',
'debugger',
'cookies', // Add this line
],
```
- [ ] **Step 2: Test build to ensure manifest is valid**
Run: `npm run compile`
Expected: No TypeScript errors
- [ ] **Step 3: Commit manifest changes**
```bash
git add wxt.config.ts
git commit -m "feat: add cookies permission to manifest"
```
---
## Task 3: Create Storage Cleaning Utilities
**Files:**
- Create: `utils/storageCleaner.ts`
- [ ] **Step 1: Create storage cleaning utility file with helper functions**
```typescript
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
const RESTRICTED_PROTOCOLS = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
] as const;
export async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
export function isRestrictedUrl(url?: string): boolean {
if (!url) return true;
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
export async function clearCookies(url: string): Promise<StorageCleanResult> {
try {
const cookies = await chrome.cookies.getAll({ url });
for (const cookie of cookies) {
await chrome.cookies.remove({
url,
name: cookie.name,
storeId: cookie.storeId,
});
}
return { success: true, count: cookies.length };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: () => {
const count = localStorage.length;
localStorage.clear();
return { count };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: () => {
const count = sessionStorage.length;
sessionStorage.clear();
return { count };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number } | { error: string }>({
target: { tabId },
func: () => {
if (typeof indexedDB.databases === 'function') {
return indexedDB.databases().then(async (databases) => {
let count = 0;
for (const db of databases) {
await new Promise<void>((resolve, reject) => {
const deleteReq = indexedDB.deleteDatabase(db.name);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', db.name);
};
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject();
});
count++;
}
return { count };
});
}
return { error: 'databases_api_unavailable' };
},
});
if (result.result) {
if ('error' in result.result) {
return { success: false, error: result.result.error };
}
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: async () => {
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
return { count: cacheNames.length };
}
return { count: 0 };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: async () => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
return { count: registrations.length };
}
return { count: 0 };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function clearStorage(
tabId: number,
url: string,
options: StorageCleanerOptions,
): Promise<CleaningResult> {
const result: CleaningResult = { success: true };
if (options.localStorage) {
result.localStorage = await injectClearLocalStorage(tabId);
}
if (options.sessionStorage) {
result.sessionStorage = await injectClearSessionStorage(tabId);
}
if (options.indexedDB) {
result.indexedDB = await injectClearIndexedDB(tabId);
}
if (options.cookies) {
result.cookies = await clearCookies(url);
}
if (options.cacheStorage) {
result.cacheStorage = await injectClearCacheStorage(tabId);
}
if (options.serviceWorkers) {
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
}
// Check if any operation failed
const failures = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === false,
);
if (failures.length > 0) {
result.success = false;
result.error = '部分清理失败';
}
return result;
}
export function formatCleaningResult(result: CleaningResult): string {
const parts: string[] = [];
if (result.localStorage?.success) {
parts.push(`${result.localStorage.count} 个 localStorage`);
}
if (result.sessionStorage?.success) {
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
}
if (result.indexedDB?.success) {
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
}
if (result.cookies?.success) {
parts.push(`${result.cookies.count} 个 Cookies`);
}
if (result.cacheStorage?.success) {
parts.push(`${result.cacheStorage.count} 个 Cache`);
}
if (result.serviceWorkers?.success) {
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
}
if (parts.length === 0) {
return '该页面没有可清理的存储数据';
}
return `清理了 ${parts.join(', ')}`;
}
export function isEmptyResult(result: CleaningResult): boolean {
const values = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
);
return values.length === 0;
}
```
- [ ] **Step 2: Commit storage cleaning utilities**
```bash
git add utils/storageCleaner.ts
git commit -m "feat: add storage cleaning utility functions"
```
---
## Task 4: Create StorageCleanerPage Component
**Files:**
- Create: `entrypoints/popup/pages/StorageCleanerPage.tsx`
- [ ] **Step 1: Create StorageCleanerPage component with UI and logic**
```typescript
import { useState, useEffect, useCallback } from 'react';
import {
Paper,
Typography,
Box,
Checkbox,
Button,
FormControlLabel,
Alert,
Snackbar,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning';
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
import {
getCurrentTab,
isRestrictedUrl,
clearStorage,
formatCleaningResult,
isEmptyResult,
} from '@/utils/storageCleaner';
const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
};
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
autoRefresh: true,
selectedTypes: DEFAULT_OPTIONS,
};
export default function StorageCleanerPage() {
const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>('');
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
open: false,
message: '',
});
// Load tab info and user preferences
useEffect(() => {
const loadInfo = async () => {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
setError('无法获取当前标签页');
return;
}
if (isRestrictedUrl(tab.url)) {
setError('存储清理功能不支持此页面');
return;
}
setDomain(new URL(tab.url).hostname);
// Load user preferences
const prefs = await storageUtil.get(
'storageCleaner/preferences',
DEFAULT_PREFERENCES,
);
setAutoRefresh(prefs.autoRefresh);
setOptions(prefs.selectedTypes);
};
loadInfo();
}, []);
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const handleClean = useCallback(async () => {
const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) {
setSnackbar({ open: true, message: '无法获取当前标签页' });
return;
}
setLoading(true);
try {
const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult);
// Save user preferences
await storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: options,
});
// Auto refresh if enabled
if (autoRefresh && cleaningResult.success) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id);
}, 1500);
}
} catch (err) {
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
} finally {
setLoading(false);
setShowConfirm(false);
}
}, [options, autoRefresh]);
const handleRefresh = useCallback(async () => {
const tab = await getCurrentTab();
if (tab?.id) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id);
}, 1500);
}
}, []);
if (error) {
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Alert severity="error" icon={<WarningIcon />}>
{error}
</Alert>
</Paper>
);
}
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
: {domain || '加载中...'}
</Typography>
</Box>
{/* Storage Type Options */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Checkbox
checked={options.localStorage}
onChange={() => handleOptionChange('localStorage')}
/>
}
label="localStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.sessionStorage}
onChange={() => handleOptionChange('sessionStorage')}
/>
}
label="sessionStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.indexedDB}
onChange={() => handleOptionChange('indexedDB')}
/>
}
label="IndexedDB"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cookies}
onChange={() => handleOptionChange('cookies')}
/>
}
label="Cookies"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cacheStorage}
onChange={() => handleOptionChange('cacheStorage')}
/>
}
label="Cache Storage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.serviceWorkers}
onChange={() => handleOptionChange('serviceWorkers')}
/>
}
label="Service Workers"
/>
</Box>
</Box>
{/* Auto Refresh Option */}
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
}
label="清理完成后自动刷新页面"
/>
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
onClick={() => setShowConfirm(true)}
disabled={loading}
fullWidth
>
{loading ? '清理中...' : '清理'}
</Button>
</Box>
{/* Result Display */}
{result && (
<Box sx={{ mb: 2 }}>
<Alert
severity={result.success ? 'success' : 'error'}
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
>
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
</Alert>
{!autoRefresh && result.success && (
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={handleRefresh}
fullWidth
>
</Button>
)}
</Box>
)}
{/* Confirmation Dialog */}
{showConfirm && (
<Paper
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255, 255, 255, 0.95)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
zIndex: 10,
}}
>
<Typography variant="h6"></Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
</Typography>
<Box sx={{ mb: 1 }}>
{options.localStorage && (
<Typography variant="body2">- localStorage</Typography>
)}
{options.sessionStorage && (
<Typography variant="body2">- sessionStorage</Typography>
)}
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
{options.cacheStorage && (
<Typography variant="body2">- Cache Storage</Typography>
)}
{options.serviceWorkers && (
<Typography variant="body2">- Service Workers</Typography>
)}
</Box>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', mb: 1 }}
>
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
</Button>
<Button variant="contained" color="error" onClick={handleClean}>
</Button>
</Box>
</Paper>
)}
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity="info" variant="filled">
{snackbar.message}
</Alert>
</Snackbar>
</Paper>
);
}
```
- [ ] **Step 2: Commit StorageCleanerPage component**
```bash
git add entrypoints/popup/pages/StorageCleanerPage.tsx
git commit -m "feat: add StorageCleanerPage component"
```
---
## Task 5: Update App.tsx with Tab Switching
**Files:**
- Modify: `entrypoints/popup/App.tsx`
- [ ] **Step 1: Add tab switching logic to App.tsx**
```typescript
import { useState } from 'react';
import { Box, Button } from '@mui/material';
import TimestampPage from './pages/TimestampPage';
import StorageCleanerPage from './pages/StorageCleanerPage';
import './App.css';
type PageType = 'timestamp' | 'storageCleaner';
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
return (
<div className="app">
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<Button
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('timestamp')}
>
</Button>
<Button
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('storageCleaner')}
sx={{ ml: 1 }}
>
</Button>
</Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
);
}
export default App;
```
- [ ] **Step 2: Run type check**
Run: `npm run compile`
Expected: No TypeScript errors
- [ ] **Step 3: Commit App.tsx changes**
```bash
git add entrypoints/popup/App.tsx
git commit -m "feat: add tab switching to App component"
```
---
## Task 6: Build and Test
**Files:**
- No file changes
- [ ] **Step 1: Build the extension**
Run: `npm run build`
Expected: Build succeeds with no errors
- [ ] **Step 2: Run lint check**
Run: `npm run lint`
Expected: No linting errors
- [ ] **Step 3: Load extension in Chrome for manual testing**
Instructions:
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable Developer Mode
3. Click "Load unpacked"
4. Select `.output/chrome-mv3` directory
5. Test on a regular web page (e.g., example.com)
- [ ] **Step 4: Commit successful implementation**
```bash
git commit --allow-empty -m "feat: complete storage cleaner feature implementation"
```
---
## Testing Checklist
After implementation, verify:
- [ ] Tab switching works between timestamp and storage cleaner
- [ ] Current domain displays correctly
- [ ] All storage type checkboxes toggle correctly
- [ ] Auto refresh checkbox persists across sessions
- [ ] Clear confirmation dialog appears
- [ ] Confirmation dialog shows selected storage types
- [ ] localStorage clears successfully
- [ ] sessionStorage clears successfully
- [ ] IndexedDB clears successfully (or shows error if unavailable)
- [ ] Cookies clear successfully
- [ ] Clear httponly and secure cookies
- [ ] Cache Storage clears successfully
- [ ] Service Workers unregister successfully
- [ ] Result message displays correctly
- [ ] Empty state shows friendly message
- [ ] Auto refresh works
- [ ] Manual refresh button appears when auto-refresh is off
- [ ] Restricted pages show error message
- [ ] Snackbar notifications appear correctly
- [ ] Test on localhost
---
## Rollback Plan
If issues occur during testing:
1. Revert to before implementation:
```bash
git reset --hard <commit-before-start>
```
2. Or revert specific files:
```bash
git checkout HEAD -- types/storage.d.ts wxt.config.ts utils/storageCleaner.ts entrypoints/popup/App.tsx entrypoints/popup/pages/StorageCleanerPage.tsx
```
---
## Notes
- The popup closes automatically when the page is refreshed - this is expected behavior
- IndexedDB.databases() may not be available in all browser versions; the fallback handles this
- Chrome Cookies API requires explicit permission, which is added to the manifest
- User preferences are persisted using the existing chromeStorage.ts utility
@@ -0,0 +1,387 @@
# 存储清理功能设计文档
## 概述
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers。
## 目标
- 提供便捷的页面存储清理功能
- 支持多种存储类型清理
- 提供清理结果反馈
- 支持清理后自动刷新页面
## 架构设计
### 组件结构
```
entrypoints/popup/pages/
├── TimestampPage.tsx (现有:时间戳转换页面)
└── StorageCleanerPage.tsx (新增:存储清理页面)
```
### 页面布局
在弹窗中添加标签页切换功能,用户可以在时间戳转换和存储清理之间切换。
**路由实现方案:**
使用简单的状态管理进行页面切换:
```typescript
// App.tsx
type PageType = 'timestamp' | 'storageCleaner';
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
return (
<div className="app">
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<Button
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('timestamp')}
>
</Button>
<Button
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('storageCleaner')}
sx={{ ml: 1 }}
>
</Button>
</Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
);
}
```
## 用户界面设计
### 页面组成
1. **头部区域**
- 标题:"存储清理"
- 当前域名显示(自动从活动标签页获取)
2. **存储类型选择区域**
- 勾选框:localStorage
- 勾选框:sessionStorage
- 勾选框:IndexedDB
- 勾选框:Cookies
- 勾选框:Cache Storage
- 勾选框:Service Workers
3. **自动刷新选项**
- 复选框:清理完成后自动刷新页面(默认勾选)
4. **操作区域**
- 清理按钮
5. **结果显示区域**
- 清理成功/失败提示
- 清理详情统计(如:"清理了 5 个 localStorage, 3 个 cookies"
- 刷新页面按钮(当未勾选自动刷新时显示)
## 技术实现细节
### 获取当前标签页域名
使用 Chrome Tabs API 获取当前活动标签页,并过滤受限页面:
```typescript
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// 检查受限页面
const restrictedProtocols = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
];
if (!tab?.url || restrictedProtocols.some((p) => tab.url!.startsWith(p))) {
throw new Error('存储清理功能不支持此页面');
}
const domain = new URL(tab.url).hostname;
```
### 清理 Cookies(使用 chrome.cookies API
在扩展环境中直接执行,不需要注入页面:
```typescript
const cookies = await chrome.cookies.getAll({ url: tab.url });
let count = 0;
for (const cookie of cookies) {
await chrome.cookies.remove({
url: tab.url,
name: cookie.name,
storeId: cookie.storeId,
});
count++;
}
```
### 注入脚本清理其他存储
使用 `chrome.scripting.executeScript` 注入清理脚本:
```typescript
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
// 清理逻辑在页面上下文中执行
},
});
```
需要注入到页面执行的存储清理逻辑:
#### 清理 localStorage
```javascript
const count = localStorage.length;
localStorage.clear();
return count;
```
#### 清理 sessionStorage
```javascript
const count = sessionStorage.length;
sessionStorage.clear();
return count;
```
#### 清理 IndexedDB
```javascript
// 检查 indexedDB.databases 方法是否可用
if (typeof indexedDB.databases === 'function') {
const databases = await indexedDB.databases();
let count = 0;
for (const db of databases) {
const deleteReq = indexedDB.deleteDatabase(db.name);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', db.name);
};
await new Promise((resolve, reject) => {
deleteReq.onsuccess = resolve;
deleteReq.onerror = reject;
});
count++;
}
return count;
}
// 降级方案:由于无法获取所有数据库名称,提示返回特殊值表示需要手动操作
return { error: 'databases_api_unavailable' };
```
#### 清理 Cache Storage
```javascript
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
return cacheNames.length;
}
return 0;
```
#### 注销 Service Workers
```javascript
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
let count = 0;
for (const registration of registrations) {
await registration.unregister();
count++;
}
return count;
}
return 0;
```
### 数据流
1. 页面加载时获取当前标签页 URL 并显示域名
2. 检查是否为受限页面(chrome://, about:// 等),如果是则显示错误提示
3. 用户勾选要清理的存储类型
4. 用户选择是否自动刷新页面
5. 用户点击清理按钮
6. 弹出确认对话框询问用户确认
7. 确认后执行清理:
- 如果选择 Cookies:直接使用 chrome.cookies API 删除
- 其他存储类型:向页面注入清理脚本
8. 收集所有清理结果并统计
9. 显示清理结果
10. 如果勾选"自动刷新"或用户点击"刷新页面"按钮,执行页面刷新
**注意:** 当触发页面刷新时,popup 会自动关闭。需要在刷新前显示提示信息。
### 页面刷新
```typescript
// 显示刷新提示
setRefreshing(true);
setTimeout(async () => {
await chrome.tabs.reload(tab.id);
}, 1500); // 1.5秒延迟让用户看到提示信息
```
### 空状态处理
当所有存储类型清理返回 0 时,显示友好的提示:
```
该页面没有可清理的存储数据
```
## 错误处理
| 错误场景 | 处理方式 |
| ------------------------------- | ---------------------------------------- |
| 无法获取当前标签页 | 显示错误提示:"无法获取当前标签页" |
| 受限页面(chrome://, about:// | 显示错误提示:"存储清理功能不支持此页面" |
| 无法访问页面 URL | 显示错误提示:"无法访问此页面" |
| IndexedDB onblocked | 显示警告但继续执行其他清理 |
| IndexedDB.databases 不可用 | 使用降级方案或提示用户手动清除 |
| 清理失败 | 显示具体错误信息 |
| Cookies 删除失败 | 记录错误,显示清理失败提示 |
| 无权限 | 提示用户刷新扩展或检查权限 |
| 脚本注入失败 | 显示错误提示:"无法注入清理脚本" |
**Popup 生命周期说明:**
- Popup 在页面失去焦点时会关闭
- 刷新页面后 Popup 会自动关闭
- 需要在刷新前显示提示:"页面即将刷新,Popup 将关闭"
## 用户偏好持久化
使用现有的 `chromeStorage.ts` 工具保存用户偏好:
```typescript
// 保存用户偏好
await storageUtil.set('storageCleaner/preferences', {
autoRefresh: true, // 默认勾选自动刷新
selectedTypes: {
// 可以保存用户上次选择的存储类型
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
},
});
// 读取用户偏好
const preferences = await storageUtil.get('storageCleaner/preferences', {
autoRefresh: true,
selectedTypes: {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
},
});
```
## 权限需求
需要在 manifest 中添加 `cookies` 权限:
```typescript
permissions: [
'storage',
'unlimitedStorage',
'clipboardWrite',
'activeTab',
'scripting',
'tabs',
'debugger',
'cookies', // 新增
],
```
## TypeScript 类型定义
在现有 `types/storage.d.ts` 中添加存储清理相关的类型:
```typescript
export interface StorageSchema {
'app/lastRoute': string;
'app/theme': string;
'storageCleaner/preferences': StorageCleanerPreferences;
}
export interface StorageCleanerPreferences {
autoRefresh: boolean;
selectedTypes: StorageCleanerOptions;
}
export interface StorageCleanerOptions {
localStorage: boolean;
sessionStorage: boolean;
indexedDB: boolean;
cookies: boolean;
cacheStorage: boolean;
serviceWorkers: boolean;
}
export type StorageCleanResult =
| {
success: true;
count: number;
}
| {
success: false;
error: string;
};
export interface CleaningResult {
success: boolean;
error?: string;
localStorage?: StorageCleanResult;
sessionStorage?: StorageCleanResult;
indexedDB?: StorageCleanResult;
cookies?: StorageCleanResult;
cacheStorage?: StorageCleanResult;
serviceWorkers?: StorageCleanResult;
}
```
## 测试计划
1. 测试各种存储类型的单独清理
2. 测试同时清理多种存储类型
3. 测试自动刷新功能
4. 测试手动刷新按钮
5. 测试无存储数据时的清理(显示空状态提示)
6. 测试无法访问页面的错误处理
7. 测试受限页面(chrome://, about://, file://, data://
8. 测试 IndexedDB onblocked 场景
9. 测试 httponly 和 secure cookies 清理
10. 测试本地开发环境(localhost)
11. 测试用户偏好持久化
## 后续优化
- 显示清理前的存储使用情况
- 支持批量清理多个标签页
- 支持自定义域名清理
+51
View File
@@ -0,0 +1,51 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
export default defineBackground(() => {
// 监听扩展安装或更新事件
browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') {
console.log('Extension installed for the first time');
} else if (reason === 'update') {
console.log('Extension updated to a new version');
}
// 获取所有标签页
const tabs = await browser.tabs.query({});
// 过滤不合法或受限制的 URL
const targetTabs = tabs.filter((tab) => {
if (!tab.id || !tab.url) return false;
const restrictedProtocols = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
];
return !restrictedProtocols.some((protocol) => tab.url!.startsWith(protocol));
});
const results = await Promise.allSettled(
targetTabs.map((tab) =>
browser.scripting
.executeScript({
target: { tabId: tab.id! },
files: ['/content-scripts/content.js'],
})
.catch((err) => {
console.warn(`Failed to inject script into tab ${tab.id}:`, err.message);
}),
),
);
const successCount = results.filter((r) => r.status === 'fulfilled').length;
console.log(
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
);
});
chrome.tabs.onUpdated.addListener((tabId) => {
console.log('加载完成的 Tab ID:', tabId);
});
});
+9
View File
@@ -0,0 +1,9 @@
import '../.wxt/types/imports.d.ts';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
// Content script placeholder
},
});
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
Hello Options Page
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
/* 隐藏页面滚动条 */
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
scrollbar-gutter: stable;
}
/* 隐藏body滚动条但允许滚动 */
body {
overflow: auto;
}
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
}
.app {
width: 400px;
min-height: 100%;
margin: 0 auto;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow-x: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 导航容器 */
.nav-container {
display: flex;
justify-content: center;
margin-top: 16px;
margin-bottom: 16px;
gap: 8px;
}
/* 导航按钮 */
.nav-button {
padding: 8px 16px;
border: 1px solid var(--btn-bg);
border-radius: var(--radius);
background: transparent;
color: var(--btn-text);
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
}
.nav-button:hover {
background: var(--btn-bg);
}
.nav-button.active {
background: var(--btn-bg);
font-weight: 500;
}
+88
View File
@@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import { Box } from '@mui/material';
import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import TimestampPage from './pages/TimestampPage';
import StorageCleanerPage from './pages/StorageCleanerPage';
import './App.css';
const PAGE_CONFIG = {
timestamp: { label: '时间戳', defaultVisible: true },
storageCleaner: { label: '存储清理', defaultVisible: true },
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
const [visiblePages, setVisiblePages] = useState<PageType[]>(['timestamp', 'storageCleaner']);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
loadInitialData();
}, []);
useEffect(() => {
if (isLoaded) {
storageUtil.set('app/currentRoute', currentPage);
}
}, [currentPage, isLoaded]);
const loadInitialData = async () => {
try {
const [savedRoute, savedVisiblePages] = await Promise.all([
storageUtil.get('app/currentRoute', 'timestamp'),
storageUtil.get('app/visiblePages', ['timestamp', 'storageCleaner'] as PageType[]),
]);
if (savedRoute) {
setCurrentPage(savedRoute);
}
if (savedVisiblePages) {
setVisiblePages(savedVisiblePages);
}
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
setIsLoaded(true);
}
};
const handlePageChange = (page: PageType) => {
setCurrentPage(page);
};
const NavButton = ({ pageKey }: { pageKey: PageType }) => {
const config = PAGE_CONFIG[pageKey];
if (!config) return null;
return (
<Box key={pageKey}>
<button
className={currentPage === pageKey ? 'nav-button active' : 'nav-button'}
onClick={() => handlePageChange(pageKey)}
>
{config.label}
</button>
</Box>
);
};
if (!isLoaded) {
return <div className="app">Loading...</div>;
}
return (
<div className="app">
<Box className="nav-container">
{(Object.keys(PAGE_CONFIG) as PageType[])
.filter((key) => visiblePages.includes(key))
.map((key) => <NavButton key={key} pageKey={key} />)}
</Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
);
}
export default App;
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Tools - Side Panel</title>
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
@@ -1,10 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<React.StrictMode>
<App />
</AppRoot>,
</React.StrictMode>,
);
@@ -0,0 +1,328 @@
import { useState, useEffect, useCallback } from 'react';
import {
Paper,
Typography,
Box,
Checkbox,
Button,
FormControlLabel,
Alert,
Snackbar,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning';
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
import {
getCurrentTab,
isRestrictedUrl,
clearStorage,
formatCleaningResult,
} from '@/utils/storageCleaner';
const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
};
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
autoRefresh: true,
selectedTypes: DEFAULT_OPTIONS,
};
export default function StorageCleanerPage() {
const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>('');
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
open: false,
message: '',
});
// Load tab info and user preferences
useEffect(() => {
const loadInfo = async () => {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
setError('无法获取当前标签页');
return;
}
if (isRestrictedUrl(tab.url)) {
setError('存储清理功能不支持此页面');
return;
}
setDomain(new URL(tab.url).hostname);
// Load user preferences
const prefs = await storageUtil.get(
'storageCleaner/preferences',
DEFAULT_PREFERENCES,
);
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
};
loadInfo();
}, []);
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const handleClean = useCallback(async () => {
const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) {
setSnackbar({ open: true, message: '无法获取当前标签页' });
return;
}
setLoading(true);
try {
const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult);
// Save user preferences
await storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: options,
});
// Auto refresh if enabled
if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id!);
}, 1500);
}
} catch (err) {
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
} finally {
setLoading(false);
setShowConfirm(false);
}
}, [options, autoRefresh]);
const handleRefresh = useCallback(async () => {
const tab = await getCurrentTab();
if (tab?.id !== undefined) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id!);
}, 1500);
}
}, []);
if (error) {
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Alert severity="error" icon={<WarningIcon />}>
{error}
</Alert>
</Paper>
);
}
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
: {domain || '加载中...'}
</Typography>
</Box>
{/* Storage Type Options */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Checkbox
checked={options.localStorage}
onChange={() => handleOptionChange('localStorage')}
/>
}
label="localStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.sessionStorage}
onChange={() => handleOptionChange('sessionStorage')}
/>
}
label="sessionStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.indexedDB}
onChange={() => handleOptionChange('indexedDB')}
/>
}
label="IndexedDB"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cookies}
onChange={() => handleOptionChange('cookies')}
/>
}
label="Cookies"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cacheStorage}
onChange={() => handleOptionChange('cacheStorage')}
/>
}
label="Cache Storage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.serviceWorkers}
onChange={() => handleOptionChange('serviceWorkers')}
/>
}
label="Service Workers"
/>
</Box>
</Box>
{/* Auto Refresh Option */}
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
}
label="清理完成后自动刷新页面"
/>
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
onClick={() => setShowConfirm(true)}
disabled={loading}
fullWidth
>
{loading ? '清理中...' : '清理'}
</Button>
</Box>
{/* Result Display */}
{result && (
<Box sx={{ mb: 2 }}>
<Alert
severity={result.success ? 'success' : 'error'}
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
>
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
</Alert>
{!autoRefresh && result.success && (
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={handleRefresh}
fullWidth
>
</Button>
)}
</Box>
)}
{/* Confirmation Dialog */}
{showConfirm && (
<Paper
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255,255,255, 0.95)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
zIndex: 10,
}}
>
<Typography variant="h6"></Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
</Typography>
<Box sx={{ mb: 1 }}>
{options.localStorage && (
<Typography variant="body2">- localStorage</Typography>
)}
{options.sessionStorage && (
<Typography variant="body2">- sessionStorage</Typography>
)}
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
{options.cacheStorage && (
<Typography variant="body2">- Cache Storage</Typography>
)}
{options.serviceWorkers && (
<Typography variant="body2">- Service Workers</Typography>
)}
</Box>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', mb: 1 }}
>
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
</Button>
<Button variant="contained" color="error" onClick={handleClean}>
</Button>
</Box>
</Paper>
)}
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity="info" variant="filled">
{snackbar.message}
</Alert>
</Snackbar>
</Paper>
);
}
+412
View File
@@ -0,0 +1,412 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import dayjs from '@/utils/dayjs';
import {
Button,
TextField,
Select,
MenuItem,
Paper,
Stack,
Typography,
Box,
IconButton,
Snackbar,
Alert,
InputAdornment,
alpha,
Tooltip,
Theme,
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import CheckIcon from '@mui/icons-material/Check';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
// ================= 常量配置 =================
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
type UnitType = 'ms' | 's';
type ZoneType = (typeof ZONES)[number];
const INPUT_STYLE = {
'& .MuiOutlinedInput-root': {
bgcolor: 'grey.50',
borderRadius: 3,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { bgcolor: 'grey.100' },
'&.Mui-focused': {
bgcolor: '#fff',
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
},
'&.Mui-error': {
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
},
},
'& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
};
// ================= 子组件:实时时钟 =================
interface LiveClockProps {
unit: UnitType;
onCopy: (val: string) => void;
onUseNow: (val: number) => void;
}
const LiveClock = React.memo(({
unit,
onCopy,
onUseNow
}: LiveClockProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const displayVal = useMemo(() =>
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
[now, unit]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
<Stack direction="row" spacing={1} alignItems="baseline">
<Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
{displayVal}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
{unit}
</Typography>
</Stack>
<Stack direction="row" spacing={0.5}>
<Tooltip title="填充到下方">
<IconButton
aria-label="use current time"
size="small"
onClick={() => onUseNow(now)}
sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }}
>
<AccessTimeIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="复制当前时间戳">
<IconButton
aria-label="copy current timestamp"
size="small"
onClick={() => onCopy(displayVal)}
sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
);
});
LiveClock.displayName = 'LiveClock';
// ================= 子组件:多维度结果展示 =================
interface ResultViewProps {
result: string;
mode: 'ts2dt' | 'dt2ts';
unit: UnitType;
zone: string;
onCopy: (val: string) => void;
}
const ResultView = React.memo(({
result,
mode,
unit,
zone,
onCopy
}: ResultViewProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
onCopy(result);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [onCopy, result]);
const extraInfo = useMemo(() => {
if (!result) return null;
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result)));
return {
relative: d.fromNow(),
iso: d.toISOString(),
utc: d.utc().format(DATE_FORMAT) + ' UTC',
};
}, [result, mode, zone, unit]);
if (!result) return null;
return (
<Box sx={{
mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
animation: 'fadeIn 0.3s ease-out',
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
}}>
<Typography variant="caption" sx={{ color: 'text.disabled', mb: 1, display: 'block', ml: 1, fontWeight: 500 }}>
</Typography>
<TextField
fullWidth
value={result}
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="copy result"
size="small"
onClick={handleCopy}
sx={{
color: copied ? 'success.main' : 'primary.main',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: copied ? 'scale(1.2)' : 'scale(1)',
}}
>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
sx={{
...INPUT_STYLE,
mb: 2,
'& .MuiOutlinedInput-root': {
...INPUT_STYLE['& .MuiOutlinedInput-root'],
bgcolor: alpha('#2563eb', 0.03),
},
}}
/>
{/* 辅助信息预览 */}
<Stack spacing={1} sx={{ px: 1 }}>
{[
{ label: '相对时间', value: extraInfo?.relative },
{ label: 'ISO 8601', value: extraInfo?.iso },
{ label: 'UTC 时间', value: extraInfo?.utc },
].map((item) => (
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
<Typography
variant="caption"
onClick={() => { if (item.value) onCopy(item.value); }}
sx={{
fontFamily: 'monospace',
color: 'text.primary',
cursor: 'pointer',
'&:hover': { color: 'primary.main', textDecoration: 'underline' }
}}
>
{item.value}
</Typography>
</Box>
))}
</Stack>
</Box>
);
});
ResultView.displayName = 'ResultView';
// ================= 主页面组件 =================
export default function TimestampPage() {
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
const [tsInput, setTsInput] = useState(() => String(Date.now()));
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
const [unit, setUnit] = useState<UnitType>('ms');
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setSnack({ open: true, msg: '已复制' });
} catch {
setSnack({ open: true, msg: '复制失败' });
}
}, []);
const convert = useCallback(() => {
if (mode === 'ts2dt') {
const rawInput = tsInput.trim();
if (!rawInput) return;
const num = Number(rawInput);
if (isNaN(num)) { setError('无效数字'); return; }
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
if (!d.isValid()) { setError('无效时间戳'); return; }
setError('');
setResult(d.tz(zone).format(DATE_FORMAT));
} else {
const rawInput = dtInput.trim();
if (!rawInput) return;
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
if (!d.isValid()) { setError('格式错误'); return; }
setError('');
const ms = d.valueOf();
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
}
}, [mode, tsInput, dtInput, unit, zone]);
// 智能实时转换 (Debounce Effect)
useEffect(() => {
const timer = setTimeout(() => {
convert();
}, 400);
return () => {
clearTimeout(timer);
};
}, [convert]);
const handleUseNow = useCallback((now: number) => {
if (mode === 'ts2dt') {
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
} else {
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
}
}, [mode, unit, zone]);
return (
<Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
}}
>
{/* 1. 实时时钟 */}
<Box sx={{ position: 'relative' }}>
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
<Tooltip title="切换单位">
<IconButton
aria-label="switch unit"
size="small"
onClick={() => { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }}
sx={{
position: 'absolute', right: 80, top: 4, color: 'grey.400',
transition: 'transform 0.3s ease',
'&:hover': { transform: 'rotate(180deg)', color: 'primary.main' }
}}
>
<SwapHorizIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* 2. 模式切换 */}
<Box sx={{ position: 'relative', display: 'flex', p: 0.5, bgcolor: 'grey.100', borderRadius: 3.5, mb: 3, overflow: 'hidden' }}>
<Box
sx={{
position: 'absolute', height: 'calc(100% - 8px)', width: 'calc(50% - 4px)',
bgcolor: '#fff', borderRadius: 3, boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
top: 4, left: 4,
}}
/>
{(['ts2dt', 'dt2ts'] as const).map((m) => (
<Button
key={m} fullWidth disableRipple
onClick={() => { setMode(m); setError(''); setResult(''); }}
sx={{
position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
color: mode === m ? 'text.primary' : 'text.disabled',
'&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
}}
>
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
</Button>
))}
</Box>
{/* 3. 输入与设置 */}
<Stack spacing={2} sx={{ mb: 3 }}>
<TextField
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
value={mode === 'ts2dt' ? tsInput : dtInput}
onChange={(e) => {
const val = e.target.value;
if (mode === 'ts2dt') {
setTsInput(val);
} else {
setDtInput(val);
}
setError('');
}}
error={!!error}
helperText={error}
fullWidth
sx={INPUT_STYLE}
/>
<Stack direction="row" spacing={2}>
<Select
fullWidth value={unit}
onChange={(e) => { setUnit(e.target.value as UnitType); }}
sx={{ ...INPUT_STYLE, flex: 1 }}
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
>
<MenuItem value="ms"> (ms)</MenuItem>
<MenuItem value="s"> (s)</MenuItem>
</Select>
<Select
fullWidth value={zone}
onChange={(e) => { setZone(e.target.value as ZoneType); }}
sx={{ ...INPUT_STYLE, flex: 1.5 }}
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
>
{ZONES.map((z) => (
<MenuItem key={z} value={z}>{z}</MenuItem>
))}
</Select>
</Stack>
</Stack>
{/* 4. 转换操作 (作为手动确认) */}
<Button
fullWidth variant="contained" disableElevation disableRipple
onClick={convert}
sx={{
py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
bgcolor: 'primary.main', transition: 'all 0.2s',
'&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
'&:active': { transform: 'translateY(0)' }
}}
>
</Button>
{/* 5. 结果展示 */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
</Paper>
<Snackbar
open={snack.open} autoHideDuration={1500}
onClose={() => { setSnack((s) => ({ ...s, open: false })); }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" variant="filled" icon={false} sx={{ borderRadius: 2.5, bgcolor: 'grey.900' }}>
{snack.msg}
</Alert>
</Snackbar>
</Box>
);
}
+18
View File
@@ -0,0 +1,18 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit; /* 先全部重置为继承大小 */
font-weight: inherit;
}
+15 -40
View File
@@ -1,69 +1,44 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import reactPlugin from 'eslint-plugin-react';
import * as reactHooks from 'eslint-plugin-react-hooks';
import * as reactPlugin from 'eslint-plugin-react';
import globals from 'globals';
export default tseslint.config(
{
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
},
js.configs.recommended,
...tseslint.configs.recommended,
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] },
{
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
files: [
'hooks/**/*.{ts,tsx}',
'entrypoints/**/*.{ts,tsx}',
'pages/**/*.{ts,tsx}',
'utils/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',
'services/**/*.{ts,tsx}',
],
},
},
{
files: ['src/**/*.{ts,tsx}'],
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
projectService: true,
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
react: reactPlugin,
'react-hooks': reactHooks,
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了
'react-hooks': reactHooks as any,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
...reactHooks.configs.recommended.rules,
...reactPlugin.configs.recommended.rules,
...reactPlugin.configs['jsx-runtime'].rules,
'react/prop-types': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'react/react-in-jsx-scope': 'off',
},
},
+11 -8
View File
@@ -1,12 +1,15 @@
/* global process */
const isCI = process.env.CI === 'true';
export default {
'*.{ts,tsx,js,jsx,mjs}': [
'eslint --fix --no-warn-ignored',
...(isCI ? ['eslint --max-warnings=0'] : []),
'prettier --write',
// 对于代码文件:
'*.{ts,tsx,js,jsx}': [
// 1. ESLint: 依然检查具体文件,拦截未使用变量
'eslint --fix --max-warnings=0 --no-warn-ignored',
// 2. TypeScript: 使用函数形式
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好”
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
() => 'tsc --noEmit --skipLibCheck',
],
// 对于其他文件:
'*.{json,css,scss,md}': ['prettier --write'],
};
+3043 -5209
View File
File diff suppressed because it is too large Load Diff
+28 -54
View File
@@ -1,9 +1,8 @@
{
"name": "testing-tools",
"description": "A browser extension providing useful testing tools including timestamp conversion and storage management",
"private": false,
"version": "1.0.0",
"license": "MIT",
"name": "wxt-react-starter",
"description": "manifest.json description",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "wxt",
@@ -12,65 +11,40 @@
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare",
"prepare": "husky",
"lint": "eslint . --max-warnings=0",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit"
"lint": "eslint . --max-warnings=0"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@testing-library/dom": "^10.4.1",
"@vitest/coverage-v8": "^4.1.7",
"@webext-core/messaging": "^3.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"lucide-react": "^1.16.0",
"qr-scanner": "^1.4.2",
"qrious": "^4.0.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.1.42",
"@types/react": "^19.2.15",
"@types/chrome": "^0.1.36",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/webextension-polyfill": "^0.12.5",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"@vitejs/plugin-react": "^6.0.2",
"@wxt-dev/module-react": "^1.2.2",
"autoprefixer": "^10.5.0",
"@types/webextension-polyfill": "^0.12.4",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^4.3.4",
"@wxt-dev/module-react": "^1.1.5",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.6.0",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.2.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.5",
"postcss": "^8.5.15",
"prettier": "^3.8.3",
"tailwindcss": "^3.4.19",
"terser": "^5.47.1",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
"terser": "^5.46.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.59.4",
"vitest": "^4.1.7",
"wxt": "^0.20.26"
"typescript-eslint": "^8.54.0",
"wxt": "^0.20.6"
}
}
-6
View File
@@ -1,6 +0,0 @@
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
export default {
plugins: [tailwindcss, autoprefixer],
};
-20
View File
@@ -1,20 +0,0 @@
# 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,确保透明背景
-924
View File
@@ -1,924 +0,0 @@
{
"buttons_copy": {
"message": "复制",
"description": "Translation key: buttons_copy"
},
"buttons_clear": {
"message": "清理",
"description": "Translation key: buttons_clear"
},
"messages_copySuccess": {
"message": "已复制到剪贴板",
"description": "Translation key: messages_copySuccess"
},
"messages_copyError": {
"message": "复制失败",
"description": "Translation key: messages_copyError"
},
"messages_copyEmpty": {
"message": "无内容可复制",
"description": "Translation key: messages_copyEmpty"
},
"errorBoundary_title": {
"message": "糟糕,出了点问题",
"description": "Translation key: errorBoundary_title"
},
"errorBoundary_description": {
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"description": "Translation key: errorBoundary_description"
},
"errorBoundary_refresh": {
"message": "刷新应用",
"description": "Translation key: errorBoundary_refresh"
},
"errorBoundary_retry": {
"message": "重新尝试",
"description": "Translation key: errorBoundary_retry"
},
"pageErrorBoundary_title": {
"message": "该功能运行异常",
"description": "Translation key: pageErrorBoundary_title"
},
"pageErrorBoundary_description": {
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
"description": "Translation key: pageErrorBoundary_description"
},
"router_notFound": {
"message": "页面未找到",
"description": "Translation key: router_notFound"
},
"router_notFoundDescription": {
"message": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。",
"description": "Translation key: router_notFoundDescription"
},
"textInputArea_clear": {
"message": "清空",
"description": "Translation key: textInputArea_clear"
},
"textInputArea_copyContent": {
"message": "复制内容",
"description": "Translation key: textInputArea_copyContent"
},
"textInputArea_cleared": {
"message": "已清空",
"description": "Translation key: textInputArea_cleared"
},
"textInputArea_placeholder": {
"message": "请输入文本",
"description": "Translation key: textInputArea_placeholder"
},
"dashboard_title": {
"message": "仪表盘",
"description": "Translation key: dashboard_title"
},
"dashboard_searchPlaceholder": {
"message": "搜索工具...",
"description": "Translation key: dashboard_searchPlaceholder"
},
"dashboard_recentlyUsed": {
"message": "最近使用",
"description": "Translation key: dashboard_recentlyUsed"
},
"dashboard_allTools": {
"message": "全部工具",
"description": "Translation key: dashboard_allTools"
},
"timestamp_title": {
"message": "时间戳",
"description": "Translation key: timestamp_title"
},
"timestamp_description": {
"message": "Unix 毫秒数转换与格式化",
"description": "Translation key: timestamp_description"
},
"storageCleaner_title": {
"message": "存储清理",
"description": "Translation key: storageCleaner_title"
},
"storageCleaner_description": {
"message": "清理缓存、Cookies 及本地存储",
"description": "Translation key: storageCleaner_description"
},
"qrCode_title": {
"message": "二维码工具",
"description": "Translation key: qrCode_title"
},
"qrCode_description": {
"message": "生成当前选中的 URL 的二维码",
"description": "Translation key: qrCode_description"
},
"textStatistics_title": {
"message": "文本统计",
"description": "Translation key: textStatistics_title"
},
"textStatistics_description": {
"message": "实时分析文本字符、单词及字节",
"description": "Translation key: textStatistics_description"
},
"jwt_title": {
"message": "JWT 解析",
"description": "Translation key: jwt_title"
},
"jwt_description": {
"message": "JSON Web Token 解码与查看",
"description": "Translation key: jwt_description"
},
"jsonDiff_title": {
"message": "JSON 工具",
"description": "Translation key: jsonDiff_title"
},
"jsonDiff_description": {
"message": "差异比较、格式化、YAML/TOML 转换及压缩",
"description": "Translation key: jsonDiff_description"
},
"base64Converter_title": {
"message": "Base64 转换器",
"description": "Translation key: base64Converter_title"
},
"base64Converter_description": {
"message": "文本、文件与图像的 Base64 编码转换",
"description": "Translation key: base64Converter_description"
},
"rightClickRestorer_title": {
"message": "右键恢复",
"description": "Translation key: rightClickRestorer_title"
},
"rightClickRestorer_description": {
"message": "检测并恢复被网站禁用的浏览器右键菜单",
"description": "Translation key: rightClickRestorer_description"
},
"base64Converter_pageTitle": {
"message": "Base64 转换器",
"description": "Translation key: base64Converter_pageTitle"
},
"base64Converter_textMode": {
"message": "文本",
"description": "Translation key: base64Converter_textMode"
},
"base64Converter_fileMode": {
"message": "文件",
"description": "Translation key: base64Converter_fileMode"
},
"base64Converter_imageMode": {
"message": "图像",
"description": "Translation key: base64Converter_imageMode"
},
"base64Converter_encode": {
"message": "编码",
"description": "Translation key: base64Converter_encode"
},
"base64Converter_decode": {
"message": "解码",
"description": "Translation key: base64Converter_decode"
},
"base64Converter_clear": {
"message": "清空",
"description": "Translation key: base64Converter_clear"
},
"base64Converter_textInputPlaceholder": {
"message": "输入需要编码为 Base64 的文本...",
"description": "Translation key: base64Converter_textInputPlaceholder"
},
"base64Converter_base64InputPlaceholder": {
"message": "输入需要解码的 Base64 字符串...",
"description": "Translation key: base64Converter_base64InputPlaceholder"
},
"base64Converter_base64Output": {
"message": "Base64 编码结果",
"description": "Translation key: base64Converter_base64Output"
},
"base64Converter_textOutput": {
"message": "解码文本结果",
"description": "Translation key: base64Converter_textOutput"
},
"base64Converter_copyRaw": {
"message": "复制纯 Base64",
"description": "Translation key: base64Converter_copyRaw"
},
"base64Converter_copyDataUri": {
"message": "复制 Data URI",
"description": "Translation key: base64Converter_copyDataUri"
},
"base64Converter_clickOrDropToFile": {
"message": "点击或拖拽文件到此处",
"description": "Translation key: base64Converter_clickOrDropToFile"
},
"base64Converter_clickOrDropToImage": {
"message": "点击或拖拽图像到此处",
"description": "Translation key: base64Converter_clickOrDropToImage"
},
"base64Converter_clickOrDropToReplace": {
"message": "点击或拖拽以替换文件",
"description": "Translation key: base64Converter_clickOrDropToReplace"
},
"base64Converter_maxFileSize": {
"message": "最大文件大小:{{max}}",
"description": "Translation key: base64Converter_maxFileSize"
},
"base64Converter_supportedFormats": {
"message": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
"description": "Translation key: base64Converter_supportedFormats"
},
"base64Converter_fileSizeExceeded": {
"message": "文件大小超出限制(最大 {{max}}",
"description": "Translation key: base64Converter_fileSizeExceeded"
},
"base64Converter_unsupportedImageType": {
"message": "不支持的图像格式",
"description": "Translation key: base64Converter_unsupportedImageType"
},
"base64Converter_conversionFailed": {
"message": "转换失败",
"description": "Translation key: base64Converter_conversionFailed"
},
"base64Converter_originalSize": {
"message": "原始大小",
"description": "Translation key: base64Converter_originalSize"
},
"base64Converter_encodedSize": {
"message": "编码大小",
"description": "Translation key: base64Converter_encodedSize"
},
"base64Converter_invalidBase64": {
"message": "Base64 字符串无效",
"description": "Translation key: base64Converter_invalidBase64"
},
"base64Converter_binaryDataDetected": {
"message": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
"description": "Translation key: base64Converter_binaryDataDetected"
},
"base64Converter_imageDataUriHint": {
"message": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
"description": "Translation key: base64Converter_imageDataUriHint"
},
"base64Converter_switchToImageMode": {
"message": "切换到图像模式",
"description": "Translation key: base64Converter_switchToImageMode"
},
"base64Converter_download": {
"message": "下载",
"description": "Translation key: base64Converter_download"
},
"base64Converter_decodedFileName": {
"message": "解码后文件名",
"description": "Translation key: base64Converter_decodedFileName"
},
"base64Converter_decodeBase64Placeholder": {
"message": "输入需要解码的 Base64 或 data URI...",
"description": "Translation key: base64Converter_decodeBase64Placeholder"
},
"base64Converter_decodedFileOutput": {
"message": "解码文件",
"description": "Translation key: base64Converter_decodedFileOutput"
},
"base64Converter_decodedImageOutput": {
"message": "解码图像",
"description": "Translation key: base64Converter_decodedImageOutput"
},
"base64Converter_inferredMimeType": {
"message": "推断的 MIME 类型",
"description": "Translation key: base64Converter_inferredMimeType"
},
"base64Converter_decodedSize": {
"message": "解码大小",
"description": "Translation key: base64Converter_decodedSize"
},
"jsonDiff_pageTitle": {
"message": "JSON 差异比较",
"description": "Translation key: jsonDiff_pageTitle"
},
"jsonDiff_leftPlaceholder": {
"message": "输入原始 JSON...",
"description": "Translation key: jsonDiff_leftPlaceholder"
},
"jsonDiff_rightPlaceholder": {
"message": "输入目标 JSON...",
"description": "Translation key: jsonDiff_rightPlaceholder"
},
"jsonDiff_leftLabel": {
"message": "原始 JSON",
"description": "Translation key: jsonDiff_leftLabel"
},
"jsonDiff_rightLabel": {
"message": "目标 JSON",
"description": "Translation key: jsonDiff_rightLabel"
},
"jsonDiff_sideBySideMode": {
"message": "并排",
"description": "Translation key: jsonDiff_sideBySideMode"
},
"jsonDiff_unifiedMode": {
"message": "统一",
"description": "Translation key: jsonDiff_unifiedMode"
},
"jsonDiff_previousDiff": {
"message": "上一个",
"description": "Translation key: jsonDiff_previousDiff"
},
"jsonDiff_nextDiff": {
"message": "下一个",
"description": "Translation key: jsonDiff_nextDiff"
},
"jsonDiff_noDiffs": {
"message": "无差异",
"description": "Translation key: jsonDiff_noDiffs"
},
"jsonDiff_invalidJson": {
"message": "无效的 JSON 格式",
"description": "Translation key: jsonDiff_invalidJson"
},
"jsonDiff_emptyHint": {
"message": "输入两侧 JSON 后点击比较",
"description": "Translation key: jsonDiff_emptyHint"
},
"jsonDiff_fixErrorHint": {
"message": "请修正上方 JSON 的语法错误以开启实时流式比对",
"description": "Translation key: jsonDiff_fixErrorHint"
},
"jsonDiff_added": {
"message": "新增",
"description": "Translation key: jsonDiff_added"
},
"jsonDiff_removed": {
"message": "删除",
"description": "Translation key: jsonDiff_removed"
},
"jsonDiff_modified": {
"message": "修改",
"description": "Translation key: jsonDiff_modified"
},
"jsonFormat_inputPlaceholder": {
"message": "输入需要格式化的 JSON...",
"description": "Translation key: jsonFormat_inputPlaceholder"
},
"jsonFormat_sortKeys": {
"message": "键名排序",
"description": "Translation key: jsonFormat_sortKeys"
},
"jsonFormat_indentSize": {
"message": "缩进",
"description": "Translation key: jsonFormat_indentSize"
},
"jsonFormat_outputLabel": {
"message": "格式化结果",
"description": "Translation key: jsonFormat_outputLabel"
},
"jsonFormat_invalidJson": {
"message": "无效的 JSON 格式",
"description": "Translation key: jsonFormat_invalidJson"
},
"jsonFormat_emptyHint": {
"message": "输入 JSON 后点击格式化",
"description": "Translation key: jsonFormat_emptyHint"
},
"jsonFormat_fixErrorHint": {
"message": "请修正上方 JSON 的语法错误以开启实时流式格式化",
"description": "Translation key: jsonFormat_fixErrorHint"
},
"jsonFormat_originalSize": {
"message": "原始大小",
"description": "Translation key: jsonFormat_originalSize"
},
"jsonFormat_formattedSize": {
"message": "格式化后大小",
"description": "Translation key: jsonFormat_formattedSize"
},
"jsonFormat_diffMode": {
"message": "差异比较",
"description": "Translation key: jsonFormat_diffMode"
},
"jsonFormat_formatMode": {
"message": "格式化",
"description": "Translation key: jsonFormat_formatMode"
},
"jsonFormat_yamlMode": {
"message": "YAML",
"description": "Translation key: jsonFormat_yamlMode"
},
"jsonFormat_tomlMode": {
"message": "TOML",
"description": "Translation key: jsonFormat_tomlMode"
},
"jsonFormat_minifyMode": {
"message": "压缩",
"description": "Translation key: jsonFormat_minifyMode"
},
"jsonFormat_yamlModeInputPlaceholder": {
"message": "输入需要转换的 JSON...",
"description": "Translation key: jsonFormat_yamlModeInputPlaceholder"
},
"jsonFormat_yamlModeOutputLabel": {
"message": "YAML 结果",
"description": "Translation key: jsonFormat_yamlModeOutputLabel"
},
"jsonFormat_yamlModeEmptyHint": {
"message": "输入 JSON 后点击转换",
"description": "Translation key: jsonFormat_yamlModeEmptyHint"
},
"jsonFormat_tomlModeInputPlaceholder": {
"message": "输入需要转换的 JSON...",
"description": "Translation key: jsonFormat_tomlModeInputPlaceholder"
},
"jsonFormat_tomlModeOutputLabel": {
"message": "TOML 结果",
"description": "Translation key: jsonFormat_tomlModeOutputLabel"
},
"jsonFormat_tomlModeEmptyHint": {
"message": "输入 JSON 后点击转换",
"description": "Translation key: jsonFormat_tomlModeEmptyHint"
},
"jsonFormat_minifyModeInputPlaceholder": {
"message": "输入需要压缩的 JSON...",
"description": "Translation key: jsonFormat_minifyModeInputPlaceholder"
},
"jsonFormat_minifyModeOutputLabel": {
"message": "压缩结果",
"description": "Translation key: jsonFormat_minifyModeOutputLabel"
},
"jsonFormat_minifyModeEmptyHint": {
"message": "输入 JSON 后点击压缩",
"description": "Translation key: jsonFormat_minifyModeEmptyHint"
},
"jwt_pageTitle": {
"message": "JWT 解析",
"description": "Translation key: jwt_pageTitle"
},
"jwt_placeholder": {
"message": "在此粘贴 JWT 令牌 (Encoded JWT)...",
"description": "Translation key: jwt_placeholder"
},
"jwt_headerTitle": {
"message": "HEADER: 算法 & 令牌类型",
"description": "Translation key: jwt_headerTitle"
},
"jwt_payloadTitle": {
"message": "PAYLOAD: 数据",
"description": "Translation key: jwt_payloadTitle"
},
"jwt_signatureTitle": {
"message": "签名",
"description": "Translation key: jwt_signatureTitle"
},
"jwt_noSignature": {
"message": "无签名",
"description": "Translation key: jwt_noSignature"
},
"jwt_invalidFormat": {
"message": "无法解析",
"description": "Translation key: jwt_invalidFormat"
},
"jwt_errors_invalidBase64String": {
"message": "无效的 Base64URL 字符串",
"description": "Translation key: jwt_errors_invalidBase64String"
},
"jwt_errors_failedToDecode": {
"message": "Base64URL 解码失败:",
"description": "Translation key: jwt_errors_failedToDecode"
},
"jwt_errors_invalidFormat": {
"message": "JWT 格式错误:必须包含三个由 . 分隔的部分",
"description": "Translation key: jwt_errors_invalidFormat"
},
"jwt_errors_parseHeaderFailed": {
"message": "解析 Header 失败:",
"description": "Translation key: jwt_errors_parseHeaderFailed"
},
"jwt_errors_parsePayloadFailed": {
"message": "解析 Payload 失败:",
"description": "Translation key: jwt_errors_parsePayloadFailed"
},
"qrCode_pageTitle": {
"message": "二维码工具",
"description": "Translation key: qrCode_pageTitle"
},
"qrCode_urlToQr": {
"message": "文本转二维码",
"description": "Translation key: qrCode_urlToQr"
},
"qrCode_qrToUrl": {
"message": "二维码转文本",
"description": "Translation key: qrCode_qrToUrl"
},
"qrCode_urlInputLabel": {
"message": "输入 URL 或文本",
"description": "Translation key: qrCode_urlInputLabel"
},
"qrCode_urlInputPlaceholder": {
"message": "请输入 URL 或文本内容,将自动生成二维码",
"description": "Translation key: qrCode_urlInputPlaceholder"
},
"qrCode_generating": {
"message": "生成中...",
"description": "Translation key: qrCode_generating"
},
"qrCode_qrCodeWillShow": {
"message": "二维码将显示在这里",
"description": "Translation key: qrCode_qrCodeWillShow"
},
"qrCode_downloadButton": {
"message": "下载二维码",
"description": "Translation key: qrCode_downloadButton"
},
"qrCode_copyQrButton": {
"message": "复制二维码",
"description": "Translation key: qrCode_copyQrButton"
},
"qrCode_qrCodeDownloadSuccess": {
"message": "二维码下载成功",
"description": "Translation key: qrCode_qrCodeDownloadSuccess"
},
"qrCode_qrCodeCopySuccess": {
"message": "二维码已复制到剪贴板",
"description": "Translation key: qrCode_qrCodeCopySuccess"
},
"qrCode_parseSuccess": {
"message": "二维码解析成功",
"description": "Translation key: qrCode_parseSuccess"
},
"qrCode_noQrDetected": {
"message": "未检测到二维码,请确保图片清晰且包含二维码",
"description": "Translation key: qrCode_noQrDetected"
},
"qrCode_parseError": {
"message": "解析二维码失败,请重试",
"description": "Translation key: qrCode_parseError"
},
"qrCode_copyError": {
"message": "复制失败,请重试",
"description": "Translation key: qrCode_copyError"
},
"qrCode_imagePasted": {
"message": "图片粘贴成功,正在解析...",
"description": "Translation key: qrCode_imagePasted"
},
"qrCode_imagePasteError": {
"message": "粘贴图片失败,请重试",
"description": "Translation key: qrCode_imagePasteError"
},
"qrCode_imageCleared": {
"message": "图片已清除",
"description": "Translation key: qrCode_imageCleared"
},
"qrCode_clickToUpload": {
"message": "点击、拖拽或粘贴上传二维码图片",
"description": "Translation key: qrCode_clickToUpload"
},
"qrCode_supportFormats": {
"message": "支持 PNG、JPG、WEBP、Base64 格式",
"description": "Translation key: qrCode_supportFormats"
},
"qrCode_resultLabel": {
"message": "解析结果",
"description": "Translation key: qrCode_resultLabel"
},
"qrCode_clickToChange": {
"message": "点击更换图片",
"description": "Translation key: qrCode_clickToChange"
},
"qrCode_generateButton": {
"message": "生成二维码",
"description": "Translation key: qrCode_generateButton"
},
"qrCode_editButton": {
"message": "编辑",
"description": "Translation key: qrCode_editButton"
},
"qrCode_textPreviewLabel": {
"message": "原始文本",
"description": "Translation key: qrCode_textPreviewLabel"
},
"qrCode_generateFirstHint": {
"message": "输入文本后点击「生成二维码」按钮",
"description": "Translation key: qrCode_generateFirstHint"
},
"qrCode_inputRequired": {
"message": "请输入内容",
"description": "Translation key: qrCode_inputRequired"
},
"qrCode_generateError": {
"message": "生成二维码失败,请重试",
"description": "Translation key: qrCode_generateError"
},
"qrCode_uploadedImage": {
"message": "已上传图片",
"description": "Translation key: qrCode_uploadedImage"
},
"qrCode_reuploadButton": {
"message": "重新上传",
"description": "Translation key: qrCode_reuploadButton"
},
"qrCode_parsing": {
"message": "解析中...",
"description": "Translation key: qrCode_parsing"
},
"qrCode_resultPlaceholder": {
"message": "解析结果将显示在此处",
"description": "Translation key: qrCode_resultPlaceholder"
},
"qrCode_imageToQr": {
"message": "解析图片二维码",
"description": "Translation key: qrCode_imageToQr"
},
"rightClickRestorer_loading": {
"message": "正在加载...",
"description": "Translation key: rightClickRestorer_loading"
},
"rightClickRestorer_currentDomain": {
"message": "当前域名",
"description": "Translation key: rightClickRestorer_currentDomain"
},
"rightClickRestorer_statusLocked": {
"message": "未解锁",
"description": "Translation key: rightClickRestorer_statusLocked"
},
"rightClickRestorer_statusUnlocked": {
"message": "已解锁",
"description": "Translation key: rightClickRestorer_statusUnlocked"
},
"rightClickRestorer_unsupported": {
"message": "不支持",
"description": "Translation key: rightClickRestorer_unsupported"
},
"rightClickRestorer_unsupportedDesc": {
"message": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
"description": "Translation key: rightClickRestorer_unsupportedDesc"
},
"rightClickRestorer_unlockDesc": {
"message": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
"description": "Translation key: rightClickRestorer_unlockDesc"
},
"rightClickRestorer_unlockBtn": {
"message": "解锁当前网站右键",
"description": "Translation key: rightClickRestorer_unlockBtn"
},
"rightClickRestorer_alreadyUnlocked": {
"message": "右键已解锁",
"description": "Translation key: rightClickRestorer_alreadyUnlocked"
},
"storageCleaner_pageTitle": {
"message": "存储清理",
"description": "Translation key: storageCleaner_pageTitle"
},
"storageCleaner_loading": {
"message": "加载中...",
"description": "Translation key: storageCleaner_loading"
},
"storageCleaner_initializing": {
"message": "正在读取站点数据...",
"description": "Translation key: storageCleaner_initializing"
},
"storageCleaner_cleaning": {
"message": "正在清理...",
"description": "Translation key: storageCleaner_cleaning"
},
"storageCleaner_cleanNow": {
"message": "立即清理",
"description": "Translation key: storageCleaner_cleanNow"
},
"storageCleaner_autoRefresh": {
"message": "清理后自动刷新页面",
"description": "Translation key: storageCleaner_autoRefresh"
},
"storageCleaner_selectAll": {
"message": "全选所有项",
"description": "Translation key: storageCleaner_selectAll"
},
"storageCleaner_noData": {
"message": "无数据",
"description": "Translation key: storageCleaner_noData"
},
"storageCleaner_errorNoTab": {
"message": "无法获取当前标签页",
"description": "Translation key: storageCleaner_errorNoTab"
},
"storageCleaner_errorRestricted": {
"message": "存储清理功能不支持此页面",
"description": "Translation key: storageCleaner_errorRestricted"
},
"storageCleaner_cleanSuccessReload": {
"message": "清理成功,即将刷新页面",
"description": "Translation key: storageCleaner_cleanSuccessReload"
},
"storageCleaner_errorStandardOnly": {
"message": "存储清理功能仅适用于标准网页",
"description": "Translation key: storageCleaner_errorStandardOnly"
},
"storageCleaner_confirmTitle": {
"message": "确认清理数据?",
"description": "Translation key: storageCleaner_confirmTitle"
},
"storageCleaner_confirmDesc": {
"message": "您将永久删除当前页面的以下选定存储项。",
"description": "Translation key: storageCleaner_confirmDesc"
},
"storageCleaner_irreversible": {
"message": "此操作不可撤销",
"description": "Translation key: storageCleaner_irreversible"
},
"storageCleaner_confirmAction": {
"message": "确认清理",
"description": "Translation key: storageCleaner_confirmAction"
},
"storageCleaner_cleanedSummary": {
"message": "清理了 {{items}}",
"description": "Translation key: storageCleaner_cleanedSummary"
},
"storageCleaner_noDataToClean": {
"message": "该页面没有可清理的存储数据",
"description": "Translation key: storageCleaner_noDataToClean"
},
"storageCleaner_partialFailure": {
"message": "部分清理失败",
"description": "Translation key: storageCleaner_partialFailure"
},
"storageCleaner_options_localStorage": {
"message": "Local Storage",
"description": "Translation key: storageCleaner_options_localStorage"
},
"storageCleaner_options_sessionStorage": {
"message": "Session Storage",
"description": "Translation key: storageCleaner_options_sessionStorage"
},
"storageCleaner_options_indexedDB": {
"message": "站点存储",
"description": "Translation key: storageCleaner_options_indexedDB"
},
"storageCleaner_options_cookies": {
"message": "Cookies",
"description": "Translation key: storageCleaner_options_cookies"
},
"storageCleaner_options_cacheStorage": {
"message": "Cache Storage",
"description": "Translation key: storageCleaner_options_cacheStorage"
},
"storageCleaner_options_serviceWorkers": {
"message": "Service Workers",
"description": "Translation key: storageCleaner_options_serviceWorkers"
},
"textStatistics_pageTitle": {
"message": "文本统计",
"description": "Translation key: textStatistics_pageTitle"
},
"textStatistics_placeholder": {
"message": "在此输入或粘贴文本...",
"description": "Translation key: textStatistics_placeholder"
},
"textStatistics_characters": {
"message": "字符数",
"description": "Translation key: textStatistics_characters"
},
"textStatistics_words": {
"message": "单词数",
"description": "Translation key: textStatistics_words"
},
"textStatistics_lines": {
"message": "行数",
"description": "Translation key: textStatistics_lines"
},
"textStatistics_bytes": {
"message": "字节大小",
"description": "Translation key: textStatistics_bytes"
},
"timestamp_pageTitle": {
"message": "时间戳转换",
"description": "Translation key: timestamp_pageTitle"
},
"timestamp_tsToDate": {
"message": "时间戳 → 日期",
"description": "Translation key: timestamp_tsToDate"
},
"timestamp_dateToTs": {
"message": "日期 → 时间戳",
"description": "Translation key: timestamp_dateToTs"
},
"timestamp_placeholderTs": {
"message": "输入时间戳...",
"description": "Translation key: timestamp_placeholderTs"
},
"timestamp_placeholderDate": {
"message": "YYYY-MM-DD HH:mm:ss",
"description": "Translation key: timestamp_placeholderDate"
},
"timestamp_unitMs": {
"message": "毫秒 (ms)",
"description": "Translation key: timestamp_unitMs"
},
"timestamp_unitS": {
"message": "秒 (s)",
"description": "Translation key: timestamp_unitS"
},
"timestamp_currentTs": {
"message": "当前时间戳",
"description": "Translation key: timestamp_currentTs"
},
"timestamp_useNowTooltip": {
"message": "填充到下方",
"description": "Translation key: timestamp_useNowTooltip"
},
"timestamp_copyTsTooltip": {
"message": "复制时间戳",
"description": "Translation key: timestamp_copyTsTooltip"
},
"timestamp_usedSuccess": {
"message": "已使用当前时间戳",
"description": "Translation key: timestamp_usedSuccess"
},
"timestamp_resultLabel": {
"message": "转换结果",
"description": "Translation key: timestamp_resultLabel"
},
"timestamp_copyResultTooltip": {
"message": "复制结果",
"description": "Translation key: timestamp_copyResultTooltip"
},
"timestamp_relativeTime": {
"message": "相对时间",
"description": "Translation key: timestamp_relativeTime"
},
"timestamp_iso8601": {
"message": "ISO 8601",
"description": "Translation key: timestamp_iso8601"
},
"timestamp_utcTime": {
"message": "UTC 时间",
"description": "Translation key: timestamp_utcTime"
},
"timestamp_copyTooltip": {
"message": "复制",
"description": "Translation key: timestamp_copyTooltip"
},
"timestamp_resultEmpty": {
"message": "请输入并点击转换",
"description": "Translation key: timestamp_resultEmpty"
},
"common_buttons_cancel": {
"message": "取消",
"description": "Translation key: buttons_cancel"
},
"common_buttons_copy": {
"message": "复制",
"description": "Translation key: buttons_copy"
},
"common_buttons_themeMode_light": {
"message": "切换到深色模式",
"description": "Translation key: buttons_themeMode_light"
},
"common_buttons_themeMode_dark": {
"message": "切换到系统模式",
"description": "Translation key: buttons_themeMode_dark"
},
"common_buttons_themeMode_system": {
"message": "切换到浅色模式",
"description": "Translation key: buttons_themeMode_system"
},
"common_buttons_search": {
"message": "搜索工具...",
"description": "Translation key: buttons_search"
},
"common_buttons_back": {
"message": "返回",
"description": "Translation key: buttons_back"
},
"common_buttons_clearSearch": {
"message": "清除搜索",
"description": "Translation key: buttons_clearSearch"
},
"common_buttons_recentSearch": {
"message": "最近搜索",
"description": "Translation key: buttons_recentSearch"
},
"common_buttons_noResults": {
"message": "未找到相关工具",
"description": "Translation key: buttons_noResults"
},
"common_buttons_openInTab": {
"message": "在标签页打开",
"description": "Translation key: buttons_openInTab"
},
"common_errorBoundary_title": {
"message": "糟糕,出了点问题",
"description": "Translation key: errorBoundary_title"
},
"common_errorBoundary_description": {
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
"description": "Translation key: errorBoundary_description"
},
"common_errorBoundary_refresh": {
"message": "刷新应用",
"description": "Translation key: errorBoundary_refresh"
},
"common_errorBoundary_retry": {
"message": "重新尝试",
"description": "Translation key: errorBoundary_retry"
},
"common_pageErrorBoundary_title": {
"message": "该功能运行异常",
"description": "Translation key: pageErrorBoundary_title"
},
"common_pageErrorBoundary_description": {
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
"description": "Translation key: pageErrorBoundary_description"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+14 -55
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

-24
View File
@@ -1,24 +0,0 @@
# 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 的语义化类名
-73
View File
@@ -1,73 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyTextToClipboard } from '@/utils/clipboard';
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
text: string;
tooltip?: string;
}
export const CopyButton: React.FC<CopyButtonProps> = ({
text,
tooltip,
variant = 'ghost',
size = 'icon',
className,
...props
}) => {
const { t } = useI18n('common');
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (!text) {
toast.error(t('messages.copyEmpty'));
return;
}
const success = await copyTextToClipboard(text);
if (success) {
toast.success(t('messages.copySuccess'));
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t('messages.copyError'));
}
};
return (
<button
type="button"
onClick={handleCopy}
title={tooltip ?? t('buttons.copy')}
className={cn(
buttonVariants({ variant, size }),
copied &&
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
className,
)}
{...props}
>
{copied ? (
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
) : (
<Copy className="h-[1.2em] w-[1.2em]" />
)}
</button>
);
};
export default CopyButton;
-86
View File
@@ -1,86 +0,0 @@
/**
* DecodeResultPaper
*
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
* 提取了二者 decode 输出区完全一致的结构:
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
*
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
*/
import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/utils/base64Converter';
import { useI18n } from '@/utils/chromeI18n';
interface DecodeResultPaperProps {
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput */
title: string;
/** 解码后推断的 MIME 类型 */
mimeType: string;
/** 解码后 Blob 的大小(字节) */
blobSize: number;
/** 当前文件名 */
fileName: string;
/** 文件名变更回调 */
onFileNameChange: (name: string) => void;
/** 下载按钮点击回调 */
onDownload: () => void;
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
children?: React.ReactNode;
}
export default function DecodeResultPaper({
title,
mimeType,
blobSize,
fileName,
onFileNameChange,
onDownload,
children,
}: DecodeResultPaperProps) {
const { t } = useI18n('base64Converter');
return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
{/* 标题 */}
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
{/* 可选预览内容(ImageMode 的图片) */}
{children}
{/* 文件信息 */}
<div className="flex gap-4 mb-3">
<span className="text-xs text-muted-foreground">
{t('inferredMimeType')}: {mimeType}
</span>
<span className="text-xs text-muted-foreground">
{t('decodedSize')}: {formatFileSize(blobSize)}
</span>
</div>
{/* 文件名输入 */}
<div className="mb-3">
<label className="block text-xs font-medium text-muted-foreground mb-1">
{t('decodedFileName')}
</label>
<input
type="text"
value={fileName}
onChange={(e) => onFileNameChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
{/* 下载按钮 */}
<Button
variant="default"
onClick={onDownload}
disabled={!fileName.trim()}
className="w-full rounded-lg font-bold"
>
<Download className="mr-2 h-4 w-4" />
{t('download')}
</Button>
</div>
);
}
-78
View File
@@ -1,78 +0,0 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
componentDidUpdate(prevProps: Props) {
if (this.state.hasError && prevProps.children !== this.props.children) {
this.setState({ hasError: false, error: null });
}
}
private handleReset = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<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-destructive/20 bg-destructive/5 shadow-sm">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
<AlertCircle className="h-8 w-8" />
</div>
<h2 className="text-xl font-extrabold text-destructive mb-2">
{getMessage('errorBoundary_title')}
</h2>
<p className="text-sm text-muted-foreground mb-6">
{getMessage('errorBoundary_description')}
</p>
{this.state.error && (
<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-zinc-200 selection:bg-zinc-700">
{this.state.error.toString()}
</pre>
</div>
)}
<Button
variant="destructive"
onClick={this.handleReset}
className="rounded-lg font-bold shadow-sm"
>
<RefreshCw className="mr-2 h-4 w-4" />
{getMessage('errorBoundary_refresh')}
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
export { ErrorBoundary };
export default ErrorBoundary;
-171
View File
@@ -1,171 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { Image, X } from 'lucide-react';
import { toast } from 'sonner';
import { useI18n } from '@/utils/chromeI18n';
interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 预览 URL 变更回调 */
onPreviewUrlChange: (url: string) => void;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
}
const ImageUploader = ({
selectedFile,
onFileChange,
onClearFile,
previewUrl,
onPreviewUrlChange,
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
const { t } = useI18n('qrCode');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
onPreviewUrlChange(URL.createObjectURL(file));
},
[onFileChange, onPreviewUrlChange],
);
const handleClearFile = useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
onClearFile();
toast.success(t('qrCode:imageCleared'));
}, [previewUrl, onClearFile, t]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(true);
};
const handleDragLeave = () => {
onDraggingChange(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
onDraggingChange(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
}
};
// 监听粘贴事件
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
e.preventDefault();
const file = items[i].getAsFile();
if (file) {
try {
handleFileChange(file);
toast.success(t('qrCode:imagePasted'));
} catch (error) {
console.error('处理粘贴图片失败:', error);
toast.error(t('qrCode:imagePasteError'));
}
}
break;
}
}
};
document.addEventListener('paste', handlePaste);
return () => {
document.removeEventListener('paste', handlePaste);
};
}, [handleFileChange, t]);
return (
<div
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
dragging
? 'border-green-600 bg-green-50'
: selectedFile
? 'border-green-600 bg-green-50/50'
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
className="hidden"
id="qr-code-upload"
/>
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
{selectedFile ? (
<div className="text-center w-full relative">
<div className="relative inline-block">
<img
src={previewUrl}
alt="QR Code Preview"
className="max-w-full max-h-40 rounded-lg object-contain"
/>
<button
type="button"
data-testid="ClearIcon"
onClick={(e) => {
e.stopPropagation();
handleClearFile();
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
</div>
) : (
<>
<Image
data-testid="ImageIcon"
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
/>
<span className="block text-sm text-muted-foreground mb-1">
{t('qrCode:clickToUpload')}
</span>
<span className="block text-xs text-muted-foreground">
{t('qrCode:supportFormats')}
</span>
</>
)}
</label>
</div>
);
};
export default ImageUploader;
-83
View File
@@ -1,83 +0,0 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getMessage } from '@/utils/chromeI18n';
interface Props {
children: ReactNode;
resetKey?: string | number;
}
interface State {
hasError: boolean;
error: Error | null;
}
class PageErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null,
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error in page:', error, errorInfo);
}
componentDidUpdate(prevProps: Props) {
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
this.setState({ hasError: false, error: null });
}
}
private handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
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="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
<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" />
</div>
<h3 className="text-base font-semibold text-foreground mb-1.5">
{getMessage('pageErrorBoundary_title')}
</h3>
<p className="text-xs text-muted-foreground mb-5">
{getMessage('pageErrorBoundary_description')}
</p>
{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">
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
{this.state.error.stack || this.state.error.toString()}
</pre>
</div>
)}
<Button
variant="destructive"
size="sm"
onClick={this.handleRetry}
className="font-medium shadow-sm"
>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
{getMessage('errorBoundary_retry')}
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
export { PageErrorBoundary };
export default PageErrorBoundary;
-78
View File
@@ -1,78 +0,0 @@
/**
* PageSkeleton 组件 - 页面加载骨架屏
*
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
* 避免白屏闪烁,减少布局偏移
*/
interface PageSkeletonProps {
/** 骨架屏类型 */
variant?: 'dashboard' | 'tool';
}
/**
* 仪表盘卡片骨架屏
*/
function DashboardCardSkeleton() {
return (
<div className="rounded-xl border border-border p-5 h-[100px]">
<div className="flex justify-between items-start">
<div className="flex gap-3 items-center">
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
<div>
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
</div>
</div>
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
</div>
</div>
);
}
/**
* 工具页面骨架屏
*/
function ToolPageSkeleton() {
return (
<div className="p-5">
{/* 标题区域 */}
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
{/* 输入区域 */}
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
{/* 控制栏 */}
<div className="flex gap-2 mb-4">
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
<div className="flex-1" />
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
</div>
{/* 结果区域 */}
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
</div>
);
}
/**
* 页面加载骨架屏
*
* @param props - PageSkeletonProps
* @returns 骨架屏 JSX 元素
*/
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
if (variant === 'tool') {
return <ToolPageSkeleton />;
}
return (
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
{Array.from({ length: 6 }).map((_, index) => (
<DashboardCardSkeleton key={index} />
))}
</div>
);
}
PageSkeleton.displayName = 'PageSkeleton';
-84
View File
@@ -1,84 +0,0 @@
import React from 'react';
import { Copy, Download } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
/** 二维码 Data URL */
qrCodeDataUrl: string;
/** 下载回调 */
onDownload: () => void;
/** 复制回调 */
onCopy: () => void;
/** 自定义占位文本,用于空状态提示 */
placeholderText?: string;
}
const QrCodePreview = ({
qrCodeDataUrl,
onDownload,
onCopy,
placeholderText,
className,
...props
}: QrCodePreviewProps) => {
const { t } = useI18n('qrCode');
// 空状态下的虚线骨架屏
if (!qrCodeDataUrl) {
return (
<div
className={cn(
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
className,
)}
{...props}
>
<p className="text-sm text-muted-foreground text-center">
{placeholderText || t('qrCode:qrCodeWillShow')}
</p>
</div>
);
}
return (
<div
className={cn(
'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
className,
)}
{...props}
>
<div className="flex flex-col items-center w-full max-w-xs">
{/*
二维码容器适配:
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
*/}
<div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
<img
src={qrCodeDataUrl}
alt="QR Code Preview"
className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
/>
</div>
<div className="flex w-full gap-2 mt-3">
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
<Download className="w-3.5 h-3.5 text-muted-foreground" />
<span className="truncate text-xs">{t('qrCode:downloadButton')}</span>
</Button>
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
<Copy className="w-3.5 h-3.5" />
<span className="truncate text-xs">{t('qrCode:copyQrButton')}</span>
</Button>
</div>
</div>
</div>
);
};
export default QrCodePreview;
-27
View File
@@ -1,27 +0,0 @@
# 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`
-56
View File
@@ -1,56 +0,0 @@
import { FEATURES, getEntryPointType } from '@/config/features';
import { useRouter } from '@/providers/RouterProvider';
import { Suspense } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils';
import { AlertTriangle } from 'lucide-react';
const entryPointType = getEntryPointType();
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
const { t } = useI18n('common');
const animationClass =
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
if (!isLoaded) {
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
}
const currentFeature = FEATURES.find((f) => f.key === currentPage);
const MatchedComponent = currentFeature?.components?.[entryPointType];
return (
<div
key={currentPage}
className={cn(
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
'scrollbar-gutter-stable motion-reduce:transition-none',
animationClass,
)}
>
<Suspense
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
>
<PageErrorBoundary resetKey={currentPage}>
{MatchedComponent ? (
<MatchedComponent />
) : (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
<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" />
</div>
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{t('router.notFoundDescription', { entryPointType })}
</p>
</div>
)}
</PageErrorBoundary>
</Suspense>
</div>
);
}
-68
View File
@@ -1,68 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
export interface SwitchOption<T extends string | number = string> {
value: T;
label: React.ReactNode;
}
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onChange'
> {
value: T;
options: SwitchOption<T>[];
onChange: (value: T) => void;
size?: 'small' | 'medium' | 'large';
buttonClassName?: string;
}
export default function SwitchButtonGroup<T extends string | number = string>({
value,
options,
onChange,
size = 'medium',
className,
buttonClassName,
...props
}: SwitchButtonGroupProps<T>) {
const sizeClasses = {
small: 'text-xs h-8 px-2 py-1 rounded-md',
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
large: 'text-base h-11 px-4 py-2 rounded-lg',
};
return (
<div
className={cn(
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
className,
)}
{...props}
>
{options.map((option) => {
const isSelected = value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
sizeClasses[size],
isSelected
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
: 'hover:bg-background/50 hover:text-foreground/80',
buttonClassName,
)}
>
{option.label}
</button>
);
})}
</div>
);
}
-309
View File
@@ -1,309 +0,0 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton';
export type ValidateRule = {
validator: (value: string) => boolean;
message: string;
};
export type ToolbarAction = {
key: string;
label: string;
icon?: React.ReactNode;
position?: 'top' | 'bottom';
type?: 'primary' | 'default' | 'danger';
disabled?: boolean | ((value: string) => boolean);
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
};
export interface TextInputAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
'onChange'
> {
value?: string;
defaultValue?: string;
/** 值变化回调,返回最新的字符串内容 */
onChange?: (value: string) => void;
minRows?: number;
maxRows?: number;
showCount?: boolean;
showClear?: boolean;
allowCopy?: boolean;
rules?: ValidateRule[];
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
actions?: ToolbarAction[];
topExtra?: React.ReactNode;
title?: string;
externalError?: string;
onClear?: () => void;
}
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
function ActionButton({
action,
value,
globalDisabled,
onAction,
}: {
action: ToolbarAction;
value: string;
globalDisabled: boolean;
onAction: (action: ToolbarAction) => void;
}) {
const isBtnDisabled =
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
const variantClasses = {
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
default:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
};
return (
<button
type="button"
onClick={() => onAction(action)}
disabled={isBtnDisabled || globalDisabled}
className={cn(
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'disabled:pointer-events-none disabled:opacity-50',
variantClasses[action.type || 'default'],
)}
>
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
{action.label}
</button>
);
}
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
const {
value: controlledValue,
defaultValue = '',
onChange,
placeholder: placeholderProp,
disabled = false,
readOnly = false,
autoFocus = false,
minRows = 4,
maxRows = 12,
maxLength,
className,
showCount = false,
showClear = true,
allowCopy = false,
rules = [],
validateTrigger = 'onAction',
actions = [],
topExtra,
title,
externalError,
onClear,
...restProps
} = props;
const internalRef = useRef<HTMLTextAreaElement | null>(null);
const [internalValue, setInternalValue] = useState(defaultValue);
const [error, setError] = useState<string>('');
const { t } = useI18n('common');
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const displayError = externalError ?? error;
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
const adjustHeight = useCallback(() => {
const textArea = internalRef.current;
if (!textArea) return;
// 重置高度计算
textArea.style.height = 'auto';
const computedMin = minRows * 24;
const computedMax = maxRows * 24;
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
}, [minRows, maxRows]);
React.useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
const validate = useCallback(
(val: string, trigger?: string): boolean => {
if (validateTrigger !== trigger && trigger) return true;
for (const rule of rules) {
if (!rule.validator(val)) {
setError(rule.message);
return false;
}
}
setError('');
return true;
},
[rules, validateTrigger],
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newVal = e.target.value;
if (maxLength && newVal.length > maxLength) {
const msg = t('charCount', { count: maxLength });
setError(msg);
toast.warning(msg);
return;
}
if (!isControlled) setInternalValue(newVal);
onChange?.(newVal);
if (error) setError('');
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
};
const handleBlur = () => {
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
};
const handleClear = useCallback(() => {
if (!isControlled) setInternalValue('');
onChange?.('');
setError('');
internalRef.current?.focus();
toast.success(t('textInputArea.cleared'));
onClear?.();
}, [isControlled, onChange, onClear, t]);
const handleAction = useCallback(
(action: ToolbarAction) => {
const isDisabled =
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
if (isDisabled || disabled) return;
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
action.onClick(value, {
clear: handleClear,
setError,
});
},
[value, disabled, validate, validateTrigger, handleClear],
);
const topActions = actions.filter((a) => a.position !== 'bottom');
const bottomActions = actions.filter((a) => a.position === 'bottom');
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
return (
<div className={cn('w-full flex flex-col gap-1.5', className)}>
{hasTopBar && (
<div className="flex items-center justify-between px-0.5">
<div className="flex items-center gap-2">
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
{topExtra}
</div>
<div className="flex items-center gap-1.5">
{topActions.map((action) => (
<ActionButton
key={action.key}
action={action}
value={value}
globalDisabled={disabled}
onAction={handleAction}
/>
))}
{showCount && (
<span className="text-xs text-muted-foreground tabular-nums">
{value.length}
{maxLength ? ` / ${maxLength}` : ''}
</span>
)}
</div>
</div>
)}
<div
className={cn(
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
displayError &&
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
)}
>
<textarea
ref={internalRef}
value={value}
onChange={handleChange}
onBlur={handleBlur}
disabled={disabled}
autoFocus={autoFocus}
readOnly={readOnly}
placeholder={placeholder}
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
{...restProps}
/>
{hasBottomBar && (
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
{/* 左侧自定义动作 */}
<div className="flex items-center gap-1.5 min-w-0">
{bottomActions.map((action) => (
<ActionButton
key={action.key}
action={action}
value={value}
globalDisabled={disabled}
onAction={handleAction}
/>
))}
</div>
{/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && (
<CopyButton
text={value}
tooltip={t('textInputArea.copyContent')}
size="sm"
className="h-7 w-7 p-1"
/>
)}
{showClear && value && !disabled && !readOnly && (
<button
type="button"
onClick={handleClear}
aria-label={t('textInputArea.clear')}
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
)}
</div>
{/* 错误提示 */}
{displayError && (
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
{displayError}
</p>
)}
</div>
);
});
TextInputArea.displayName = 'TextInputArea';
export default TextInputArea;
-313
View File
@@ -1,313 +0,0 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react';
import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider';
import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
import { openExtensionPage } from '@/utils/chromeTabs';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils';
const SEARCH_HISTORY_LIMIT = 10;
const SEARCH_HISTORY_DISPLAY = 5;
export default function TopBar() {
const { currentPage, goBack, navigateTo } = useRouter();
const { mode, setMode } = useThemeMode();
const { t } = useI18n(['common', 'features']);
const [searchQuery, setSearchQuery] = useState('');
const [showResults, setShowResults] = useState(false);
const [searchHistory, setSearchHistory] = useState<string[]>([]);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const handleOpenInTab = async () => {
await openExtensionPage('popup.html', { mode: 'tab' });
window.close();
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setShowResults(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Cmd/Ctrl+K 快捷键聚焦搜索框
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
setShowResults(true);
}
};
document.addEventListener('keydown', handleGlobalKeyDown);
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
}, []);
useEffect(() => {
storageUtil
.get('app/searchHistory', [])
.then((history) => {
if (history) setSearchHistory(history);
})
.catch((err) => console.error('加载搜索历史失败:', err));
}, []);
const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return [];
return FEATURES.filter((f) => {
if (f.key === 'dashboard') return false;
return (
t(f.labelKey).toLowerCase().includes(query) ||
t(f.descriptionKey).toLowerCase().includes(query)
);
});
}, [searchQuery, t]);
const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return [];
return searchHistory
.slice(0, SEARCH_HISTORY_DISPLAY)
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) }))
.filter((item) => item.feature && item.feature.key !== 'dashboard');
}, [searchHistory, searchQuery]);
const saveToHistory = async (featureKey: string) => {
if (!featureKey.trim()) return;
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice(
0,
SEARCH_HISTORY_LIMIT,
);
setSearchHistory(nextHistory);
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
};
const handleSelectFeature = (feature: FeatureConfig) => {
navigateTo(feature.key);
saveToHistory(feature.key);
setSearchQuery('');
setShowResults(false);
};
const cycleThemeMode = () => {
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
setMode(nextMap[mode]);
};
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
const handleKeyDown = (e: React.KeyboardEvent) => {
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < totalItems) {
if (searchQuery.trim()) {
handleSelectFeature(searchResults[selectedIndex]);
} else {
const selected = displayedHistory[selectedIndex];
if (selected?.feature) {
handleSelectFeature(selected.feature);
}
}
} else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]);
}
} else if (e.key === 'Escape') {
setShowResults(false);
inputRef.current?.blur();
}
};
const isDashboard = currentPage === 'dashboard';
return (
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
{/* 左侧:返回按钮区 */}
<div className="flex w-10 items-center justify-start">
{!isDashboard && (
<button
type="button"
onClick={goBack}
aria-label={t('common_buttons_back')}
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
</div>
{/* 中间:搜索容器 */}
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 group-focus-within:text-muted-foreground transition-colors pointer-events-none" />
<input
ref={inputRef}
type="text"
placeholder={t('common_buttons_search')}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setShowResults(true);
setSelectedIndex(-1);
}}
onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown}
aria-label={t('common_buttons_search')}
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
/>
{!searchQuery && (
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 pointer-events-none">
K
</kbd>
)}
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery('');
setSelectedIndex(-1);
}}
aria-label={t('common:buttons.clearSearch')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* 动态联想结果卡片 */}
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1.5 bg-popover text-popover-foreground rounded-lg shadow-lg border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-2 duration-150">
<ul role="listbox" className="p-1.5">
{searchQuery.trim() ? (
searchResults.length > 0 ? (
searchResults.map((feature, index) => (
<li
key={feature.key}
role="option"
aria-selected={selectedIndex === index}
onClick={() => handleSelectFeature(feature)}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors',
selectedIndex === index
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60',
)}
>
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
{feature.icon && <feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{t(feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{t(feature.descriptionKey)}
</p>
</div>
</li>
))
) : (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('common:buttons.noResults')}
</li>
)
) : (
<>
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
{t('common:buttons.recentSearch')}
</div>
{displayedHistory.map((item, index) => (
<li
key={item.key}
role="option"
aria-selected={selectedIndex === index}
onClick={() => item.feature && handleSelectFeature(item.feature)}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors',
selectedIndex === index
? 'bg-accent text-accent-foreground'
: 'hover:bg-muted/60',
)}
>
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
'bg-muted/80 text-muted-foreground',
)}
>
{item.feature?.icon && <item.feature.icon className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-foreground truncate">
{item.feature && t(item.feature.labelKey)}
</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{item.feature && t(item.feature.descriptionKey)}
</p>
</div>
</li>
))}
</>
)}
</ul>
</div>
)}
</div>
{/* 右侧:操作区 */}
<div className="flex items-center gap-1 shrink-0">
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
<ThemeIcon className="h-4 w-4" />
</IconButton>
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
<ExternalLink className="h-4 w-4" />
</IconButton>
</div>
</header>
);
}
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
function IconButton({
children,
onClick,
title,
}: {
children: React.ReactNode;
onClick: () => void;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{children}
</button>
);
}
@@ -1,83 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import DecodeResultPaper from '@/components/DecodeResultPaper';
describe('DecodeResultPaper 组件', () => {
const defaultProps = {
title: 'decodedFileOutput',
mimeType: 'image/png',
blobSize: 1024,
fileName: 'decoded.png',
onFileNameChange: vi.fn(),
onDownload: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('渲染测试', () => {
it('应渲染标题', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
});
it('应渲染 MIME 类型信息', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
});
it('应通过 formatFileSize 渲染文件大小', () => {
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
});
it('应渲染文件名输入框', () => {
render(<DecodeResultPaper {...defaultProps} />);
const input = screen.getByDisplayValue('decoded.png');
expect(input).toBeInTheDocument();
});
it('应渲染下载按钮', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument();
});
it('应渲染 children 内容', () => {
render(
<DecodeResultPaper {...defaultProps}>
<div data-testid="preview"></div>
</DecodeResultPaper>,
);
expect(screen.getByTestId('preview')).toBeInTheDocument();
expect(screen.getByText('预览内容')).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('修改文件名时应调用 onFileNameChange', () => {
render(<DecodeResultPaper {...defaultProps} />);
const input = screen.getByDisplayValue('decoded.png');
fireEvent.change(input, { target: { value: 'new-name.png' } });
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
});
it('点击下载按钮时应调用 onDownload', () => {
render(<DecodeResultPaper {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: '下载' }));
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
});
});
describe('按钮状态', () => {
it('文件名为空时下载按钮应禁用', () => {
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
});
it('文件名不为空时下载按钮应启用', () => {
render(<DecodeResultPaper {...defaultProps} />);
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled();
});
});
});
@@ -1,102 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
// 用于触发错误的测试子组件
function ThrowError({ message }: { message: string }): never {
throw new Error(message);
}
// 正常渲染的子组件
function NormalComponent({ text }: { text: string }) {
return <div data-testid="normal-content">{text}</div>;
}
describe('ErrorBoundary', () => {
beforeEach(() => {
// 抑制测试中故意抛出的错误日志
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('正常渲染子组件', () => {
render(
<ErrorBoundary>
<NormalComponent text="正常内容" />
</ErrorBoundary>,
);
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
});
it('子组件抛出错误时显示错误 UI', () => {
render(
<ErrorBoundary>
<ThrowError message="测试错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
});
it('children 变化时重置错误状态', async () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError message="初始错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
// 切换到正常子组件
rerender(
<ErrorBoundary>
<NormalComponent text="恢复后的内容" />
</ErrorBoundary>,
);
await waitFor(() => {
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
});
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
});
it('相同的 children 不重置错误状态', () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError message="相同子组件错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
// 用相同的 children rerender
rerender(
<ErrorBoundary>
<ThrowError message="相同子组件错误" />
</ErrorBoundary>,
);
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
});
it('错误 UI 包含刷新按钮', () => {
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
value: { reload: reloadMock },
writable: true,
});
render(
<ErrorBoundary>
<ThrowError message="按钮测试" />
</ErrorBoundary>,
);
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
expect(refreshButton).toBeInTheDocument();
refreshButton.click();
expect(reloadMock).toHaveBeenCalledTimes(1);
});
});
@@ -1,201 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
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 } };
// 模拟 URL API
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe('ImageUploader 组件', () => {
const mockOnFileChange = vi.fn();
const mockOnClearFile = vi.fn();
const mockOnPreviewUrlChange = vi.fn();
const mockOnDraggingChange = vi.fn();
const defaultProps = {
selectedFile: null,
onFileChange: mockOnFileChange,
onClearFile: mockOnClearFile,
previewUrl: '',
onPreviewUrlChange: mockOnPreviewUrlChange,
dragging: false,
onDraggingChange: mockOnDraggingChange,
};
beforeEach(() => {
vi.clearAllMocks();
mockCreateObjectURL.mockReturnValue('blob:test-url');
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当没有选中文件时应显示上传提示', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument();
expect(screen.getByText(/格式/)).toBeInTheDocument();
});
it('当没有选中文件时应显示 ImageIcon', () => {
render(<ImageUploader {...defaultProps} />);
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
});
it('当选中文件时应显示文件预览', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByText('test.png')).toBeInTheDocument();
expect(screen.getByText(/点击更换/)).toBeInTheDocument();
});
it('当选中文件时应显示预览图片', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const img = screen.getByAltText('QR Code Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'blob:test-url');
});
it('当选中文件时应显示清除按钮', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
});
it('应包含隐藏的文件输入框', () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('type', 'file');
expect(input).toHaveAttribute('accept', 'image/*');
});
});
describe('文件选择交互', () => {
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
render(<ImageUploader {...defaultProps} />);
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
await act(async () => {
fireEvent.change(input, { target: { files: [mockFile] } });
});
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
});
});
describe('拖拽交互', () => {
it('拖拽进入时应调用 onDraggingChange(true)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragOver(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
});
it('拖拽离开时应调用 onDraggingChange(false)', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
fireEvent.dragLeave(dropzone);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
});
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
const { container } = render(<ImageUploader {...defaultProps} />);
const dropzone = container.firstChild as HTMLElement;
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const dropEvent = new Event('drop', { bubbles: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
files: [mockFile],
},
});
Object.defineProperty(dropEvent, 'preventDefault', {
value: vi.fn(),
});
fireEvent(dropzone, dropEvent);
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
});
});
describe('清除文件功能', () => {
it('点击清除按钮时应调用 onClearFile', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
fireEvent.click(clearButton);
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
});
});
describe('粘贴功能', () => {
it('监听粘贴事件', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
render(<ImageUploader {...defaultProps} />);
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
it('组件卸载时应移除粘贴事件监听', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { unmount } = render(<ImageUploader {...defaultProps} />);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
});
});
describe('样式测试', () => {
it('拖拽状态时应应用拖拽样式', () => {
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
it('有文件时应应用有文件样式', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const { container } = render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
});
});
@@ -1,131 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { PageErrorBoundary } from '@/components/PageErrorBoundary';
// 用于触发错误的测试子组件
function ThrowError({ message }: { message: string }): never {
throw new Error(message);
}
// 正常渲染的子组件
function NormalComponent({ text }: { text: string }) {
return <div data-testid="normal-content">{text}</div>;
}
describe('PageErrorBoundary', () => {
beforeEach(() => {
// 抑制测试中故意抛出的错误日志
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('正常渲染子组件', () => {
render(
<PageErrorBoundary>
<NormalComponent text="正常内容" />
</PageErrorBoundary>,
);
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
});
it('子组件抛出错误时显示错误卡片 UI', () => {
render(
<PageErrorBoundary>
<ThrowError message="测试错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
});
it('点击重试按钮后恢复', async () => {
const { rerender } = render(
<PageErrorBoundary>
<ThrowError message="可恢复错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
// 将子组件替换为正常组件,然后点击重试
rerender(
<PageErrorBoundary>
<NormalComponent text="恢复后的内容" />
</PageErrorBoundary>,
);
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
retryButton.click();
await waitFor(() => {
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
});
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
});
it('resetKey 变化时自动重置错误状态', async () => {
const { rerender } = render(
<PageErrorBoundary resetKey="page-a">
<ThrowError message="页面 A 错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
// 切换 resetKey,同时提供正常子组件
rerender(
<PageErrorBoundary resetKey="page-b">
<NormalComponent text="页面 B 内容" />
</PageErrorBoundary>,
);
await waitFor(() => {
expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容');
});
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
});
it('resetKey 不变时保持错误状态', () => {
const { rerender } = render(
<PageErrorBoundary resetKey="page-a">
<ThrowError message="初始错误" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
// 仅 children 变化,resetKey 不变,错误应保持
rerender(
<PageErrorBoundary resetKey="page-a">
<NormalComponent text="新内容" />
</PageErrorBoundary>,
);
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
});
it('错误 UI 包含重试按钮', () => {
render(
<PageErrorBoundary>
<ThrowError message="按钮测试" />
</PageErrorBoundary>,
);
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
expect(retryButton).toBeInTheDocument();
});
it('错误信息以 monospace 格式显示', () => {
render(
<PageErrorBoundary>
<ThrowError message="格式化测试" />
</PageErrorBoundary>,
);
const errorText = screen.getByText(/格式化测试/);
expect(errorText).toBeInTheDocument();
expect(errorText.tagName.toLowerCase()).toBe('pre');
});
});
@@ -1,64 +0,0 @@
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import PageSkeleton from '@/components/PageSkeleton';
describe('PageSkeleton 组件', () => {
describe('渲染测试', () => {
it('默认应渲染 dashboard 骨架屏', () => {
const { container } = render(<PageSkeleton />);
// dashboard 骨架屏包含 6 个卡片
const cards = container.querySelectorAll('.rounded-xl');
expect(cards.length).toBe(6);
});
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
const { container } = render(<PageSkeleton variant="dashboard" />);
// 每个卡片有 2 个骨架元素(图标、文本),6 个卡片共 12 个
const cards = container.querySelectorAll('.rounded-xl');
expect(cards.length).toBe(6);
});
it('variant 为 tool 时应渲染工具页面骨架', () => {
const { container } = render(<PageSkeleton variant="tool" />);
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
describe('布局结构测试', () => {
it('dashboard 骨架屏应使用 grid 布局', () => {
const { container } = render(<PageSkeleton variant="dashboard" />);
const gridContainer = container.firstChild as HTMLElement;
expect(gridContainer).toHaveClass('grid');
});
it('tool 骨架屏应有内边距', () => {
const { container } = render(<PageSkeleton variant="tool" />);
const toolContainer = container.firstChild as HTMLElement;
expect(toolContainer).toHaveClass('p-5');
});
});
describe('骨架屏元素测试', () => {
it('dashboard 骨架屏应包含圆角和边框样式', () => {
const { container } = render(<PageSkeleton variant="dashboard" />);
// 获取第一个卡片容器
const card = container.querySelector('.rounded-xl.border');
expect(card).toBeInTheDocument();
});
it('tool 骨架屏应包含动画脉冲效果', () => {
const { container } = render(<PageSkeleton variant="tool" />);
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
});
});
@@ -1,92 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import QrCodePreview from '@/components/QrCodePreview';
describe('QrCodePreview 组件', () => {
const mockOnDownload = vi.fn();
const mockOnCopy = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetAllMocks();
});
describe('渲染测试', () => {
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
const testDataUrl = 'data:image/png;base64,test123';
render(
<QrCodePreview
qrCodeDataUrl={testDataUrl}
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
const img = screen.getByAltText('QR Code Preview');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', testDataUrl);
});
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText(/下载二维码/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
expect(screen.getByText(/复制二维码/)).toBeInTheDocument();
});
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument();
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击下载按钮时应调用 onDownload 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText(/下载二维码/));
expect(mockOnDownload).toHaveBeenCalledTimes(1);
});
it('点击复制按钮时应调用 onCopy 回调', () => {
render(
<QrCodePreview
qrCodeDataUrl="data:image/png;base64,test"
onDownload={mockOnDownload}
onCopy={mockOnCopy}
/>,
);
fireEvent.click(screen.getByText(/复制二维码/));
expect(mockOnCopy).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,90 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import RouterContainer from '@/components/RouterContainer';
import { RouterProvider } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage';
import React from 'react';
const mockRouterValue = {
currentPage: 'dashboard' as PageType,
visiblePages: ['dashboard', 'timestamp'] as PageType[],
pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
syncNavigation: vi.fn(),
goBack: vi.fn(),
setVisiblePages: vi.fn(),
setPageOrder: vi.fn(),
};
vi.mock('@/providers/RouterProvider', () => ({
useRouter: () => mockRouterValue,
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
}));
describe('RouterContainer 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactElement) => {
return render(<RouterProvider>{ui}</RouterProvider>);
};
describe('渲染测试', () => {
it('isLoaded 为 false 时应渲染骨架屏', () => {
mockRouterValue.isLoaded = false;
const { container } = renderWithProvider(<RouterContainer />);
// 骨架屏使用 animate-pulse 类
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
it('isLoaded 为 true 时应渲染页面内容', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />);
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
});
});
describe('动画类测试', () => {
it('在 dashboard 页面应应用 dashboard 动画类', () => {
mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<RouterContainer />);
const box = document.querySelector('.page-transition-dashboard');
expect(box).toBeInTheDocument();
});
it('在非 dashboard 页面应应用 enter 动画类', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<RouterContainer />);
const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument();
});
});
describe('路由处理测试', () => {
it('currentPage 变化时应更新', () => {
const { rerender } = renderWithProvider(<RouterContainer />);
mockRouterValue.currentPage = 'timestamp';
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>);
const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument();
});
});
describe('页面级错误隔离', () => {
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />);
// 验证 RouterContainer 的 Box 结构存在
const routerBox = container.querySelector('.page-transition-dashboard');
expect(routerBox).toBeInTheDocument();
});
});
});
@@ -1,141 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage';
import React from 'react';
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
describe('StorageCleanerConfirm 组件', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const defaultOptions: StorageCleanerOptions = {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
};
const renderComponent = (props?: Partial<React.ComponentProps<typeof StorageCleanerConfirm>>) => {
return render(
<StorageCleanerConfirm
open={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
options={defaultOptions}
{...props}
/>,
);
};
describe('渲染测试', () => {
it('open 为 true 时应渲染对话框', () => {
renderComponent();
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument();
});
it('应显示警告信息', () => {
renderComponent();
expect(screen.getByText(/不可撤销/)).toBeInTheDocument();
});
it('应将选中的选项显示为标签', () => {
renderComponent();
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
});
it('应显示取消和确认按钮', () => {
renderComponent();
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击取消时应调用 onClose', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/ }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('点击确认时应调用 onConfirm', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
describe('选项过滤测试', () => {
it('应仅显示选中的选项', () => {
const partialOptions: StorageCleanerOptions = {
localStorage: true,
sessionStorage: false,
indexedDB: true,
cookies: false,
cacheStorage: false,
serviceWorkers: false,
};
renderComponent({ options: partialOptions });
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
expect(screen.getByText(/站点存储/)).toBeInTheDocument();
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
});
it('应处理空选项', () => {
const emptyOptions: StorageCleanerOptions = {
localStorage: false,
sessionStorage: false,
indexedDB: false,
cookies: false,
cacheStorage: false,
serviceWorkers: false,
};
renderComponent({ options: emptyOptions });
const chips = screen.queryAllByRole('button');
expect(chips.length).toBeGreaterThanOrEqual(2);
});
});
describe('对话框行为测试', () => {
it('open 为 false 时不应渲染', () => {
renderComponent({ open: false });
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument();
});
it('应使用不同选项渲染', () => {
const customOptions: StorageCleanerOptions = {
localStorage: false,
sessionStorage: true,
indexedDB: false,
cookies: true,
cacheStorage: false,
serviceWorkers: false,
};
renderComponent({ options: customOptions });
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
});
});
});
@@ -1,121 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
describe('SwitchButtonGroup 组件', () => {
const options = [
{ value: 'a', label: '选项A' },
{ value: 'b', label: '选项B' },
];
it('应渲染所有选项按钮', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument();
});
it('应高亮当前选中的按钮', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const buttonA = screen.getByRole('button', { name: /选项A/i });
const buttonB = screen.getByRole('button', { name: /选项B/i });
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
expect(buttonB).toHaveClass('hover:bg-background/50');
});
it('点击未选中按钮时应触发 onChange 并传入选中值', () => {
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');
});
it('点击已选中按钮时不应触发 onChange', () => {
const handleChange = vi.fn();
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
expect(handleChange).toHaveBeenCalledWith('a');
});
it('应支持通过 className 自定义样式', () => {
const { container } = render(
<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} className="custom-group" />,
);
const group = container.firstChild;
expect(group).toHaveClass('custom-group');
});
it('应支持 size 属性', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} size="small" />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toHaveClass('text-xs');
});
it('应支持 buttonSx 自定义按钮样式', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toBeInTheDocument();
});
it('应支持 ReactNode 类型的 label', () => {
const nodeOptions = [{ value: 'x', label: <span data-testid="custom-label"></span> }];
render(<SwitchButtonGroup value="x" options={nodeOptions} onChange={vi.fn()} />);
expect(screen.getByTestId('custom-label')).toBeInTheDocument();
});
it('默认按钮样式应禁止文字换行', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toHaveClass('whitespace-nowrap');
});
it('buttonSx 传入时应覆盖默认换行样式', () => {
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
const button = screen.getByRole('button', { name: /选项A/i });
expect(button).toBeInTheDocument();
});
describe('number 类型支持', () => {
const numberOptions = [
{ value: 2, label: '2' },
{ value: 4, label: '4' },
];
it('应支持 number 类型的 value 渲染', () => {
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /^2$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^4$/i })).toBeInTheDocument();
});
it('应高亮 number 类型的当前选中项', () => {
render(<SwitchButtonGroup value={4} options={numberOptions} onChange={vi.fn()} />);
const button2 = screen.getByRole('button', { name: /^2$/i });
const button4 = screen.getByRole('button', { name: /^4$/i });
expect(button2).toHaveClass('hover:bg-background/50');
expect(button4).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
});
it('点击 number 选项时应传回 number 值', () => {
const handleChange = vi.fn();
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: /^4$/i }));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(4);
});
});
});
@@ -1,490 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TextInputArea from '@/components/TextInputArea';
describe('TextInputArea 组件', () => {
describe('基础渲染', () => {
it('应渲染 placeholder', () => {
render(<TextInputArea placeholder="请输入文本..." />);
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
});
it('应渲染传入的 value', () => {
render(<TextInputArea value="测试内容" onChange={() => {}} />);
const textarea = screen.getByRole('textbox');
expect(textarea).toHaveValue('测试内容');
});
it('默认显示清空按钮', () => {
render(<TextInputArea value="有内容" onChange={() => {}} />);
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
});
it('无内容时清空按钮应隐藏', () => {
render(<TextInputArea value="" onChange={() => {}} />);
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
});
it('disabled 时清空按钮应隐藏', () => {
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
});
it('readOnly 时清空按钮应隐藏', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
});
it('showClear=false 时不显示清空按钮', () => {
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
});
});
describe('受控模式', () => {
it('输入时触发 onChange', () => {
const handleChange = vi.fn();
render(<TextInputArea value="" onChange={handleChange} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '新内容' } });
expect(handleChange).toHaveBeenCalledWith('新内容');
});
it('清空按钮触发 onChange("")', () => {
const handleChange = vi.fn();
render(<TextInputArea value="内容" onChange={handleChange} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(handleChange).toHaveBeenCalledWith('');
});
});
describe('非受控模式', () => {
it('defaultValue 应显示初始值', () => {
render(<TextInputArea defaultValue="初始值" />);
expect(screen.getByRole('textbox')).toHaveValue('初始值');
});
it('输入后应更新内部值', () => {
render(<TextInputArea defaultValue="" />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '新内容' } });
expect(textarea).toHaveValue('新内容');
});
it('清空按钮应清空内容', () => {
render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(screen.getByRole('textbox')).toHaveValue('');
});
});
describe('allowCopy 复制功能', () => {
it('allowCopy 且有内容时显示复制按钮', () => {
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
});
it('allowCopy 但无内容时隐藏复制按钮', () => {
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
expect(
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
});
it('allowCopy=false 时不显示复制按钮', () => {
render(<TextInputArea value="内容" onChange={() => {}} />);
expect(
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
).not.toBeInTheDocument();
});
it('复制时调用 clipboard writeText', async () => {
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
const user = userEvent.setup();
const writeTextSpy = vi
.spyOn(navigator.clipboard, 'writeText')
.mockRejectedValue(new Error('失败'));
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
});
describe('showCount 字符计数', () => {
it('显示当前字符数', () => {
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('空内容时显示 0', () => {
render(<TextInputArea value="" onChange={() => {}} showCount />);
expect(screen.getByText('0')).toBeInTheDocument();
});
it('设置 maxLength 时显示计数上限', () => {
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
expect(screen.getByText('2 / 10')).toBeInTheDocument();
});
});
describe('maxLength', () => {
it('超出 maxLength 的输入应被截断', () => {
const handleChange = vi.fn();
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '123456' } });
expect(handleChange).not.toHaveBeenCalledWith('123456');
});
it('未超出 maxLength 的输入应正常触发', () => {
const handleChange = vi.fn();
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: '123' } });
expect(handleChange).toHaveBeenCalledWith('123');
});
});
describe('验证规则', () => {
it('onChange 触发时验证失败应设置 error', () => {
render(
<TextInputArea
value=""
onChange={() => {}}
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'ab' } });
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
it('onBlur 触发时验证失败应设置 error', () => {
render(
<TextInputArea
value="ab"
onChange={() => {}}
validateTrigger="onBlur"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
const textarea = screen.getByRole('textbox');
fireEvent.blur(textarea);
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
it('验证通过不应显示错误', () => {
render(
<TextInputArea
value="abc"
onChange={() => {}}
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'abcd' } });
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
});
it('onAction 触发时验证失败应阻止 action 执行', () => {
const handleAction = vi.fn();
render(
<TextInputArea
value="ab"
onChange={() => {}}
validateTrigger="onAction"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
actions={[
{
key: 'test',
label: '执行',
onClick: handleAction,
},
]}
/>,
);
fireEvent.click(screen.getByText('执行'));
expect(handleAction).not.toHaveBeenCalled();
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
});
describe('操作栏 actions', () => {
it('应渲染顶部操作按钮', () => {
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
/>,
);
expect(screen.getByText('顶部操作')).toBeInTheDocument();
});
it('应渲染底部操作按钮', () => {
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
]}
/>,
);
expect(screen.getByText('底部操作')).toBeInTheDocument();
});
it('点击操作按钮触发 onClick', () => {
const handleClick = vi.fn();
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
/>,
);
fireEvent.click(screen.getByText('操作'));
expect(handleClick).toHaveBeenCalledWith(
'内容',
expect.objectContaining({
clear: expect.any(Function),
setError: expect.any(Function),
}),
);
});
it('disabled 为 true 时按钮应禁用', () => {
render(
<TextInputArea
value=""
onChange={() => {}}
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
/>,
);
expect(screen.getByText('操作')).toBeDisabled();
});
it('disabled 为函数且返回 true 时按钮应禁用', () => {
render(
<TextInputArea
value=""
onChange={() => {}}
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
/>,
);
expect(screen.getByText('操作')).toBeDisabled();
});
it('primary 类型按钮应使用 contained 样式', () => {
render(
<TextInputArea
value="内容"
onChange={() => {}}
actions={[
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
]}
/>,
);
const btn = screen.getByText('主要');
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
});
});
describe('title', () => {
it('应渲染 title', () => {
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
expect(screen.getByText('输入区域')).toBeInTheDocument();
});
it('不设置 title 时不渲染标题', () => {
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
});
});
describe('disabled 和 readOnly', () => {
it('disabled 时输入框应禁用', () => {
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
expect(screen.getByRole('textbox')).toBeDisabled();
});
it('readOnly 时输入框应只读', () => {
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
expect(screen.getByRole('textbox')).not.toBeDisabled();
});
});
describe('autoFocus', () => {
it('autoFocus 应自动聚焦', () => {
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
expect(document.activeElement).toBe(screen.getByRole('textbox'));
});
});
describe('复制功能', () => {
it('复制成功时调用 clipboard writeText', async () => {
const user = userEvent.setup();
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
expect(writeTextSpy).toHaveBeenCalledWith('测试');
});
});
describe('onClear 回调', () => {
it('点击清空按钮时应调用 onClear', () => {
const handleClear = vi.fn();
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(handleClear).toHaveBeenCalledOnce();
});
it('不传 onClear 时清空按钮应正常工作', () => {
render(<TextInputArea defaultValue="内容" />);
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
expect(screen.getByRole('textbox')).toHaveValue('');
});
});
describe('externalError 外部错误', () => {
it('设置 externalError 时应显示错误状态', () => {
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
});
it('externalError 为空时应隐藏错误状态', () => {
const { rerender } = render(
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
);
expect(screen.getByText('错误')).toBeInTheDocument();
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
expect(screen.queryByText('错误')).not.toBeInTheDocument();
});
it('externalError 优先级高于内部验证错误', () => {
render(
<TextInputArea
value="ab"
onChange={() => {}}
externalError="外部错误"
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
/>,
);
expect(screen.getByText('外部错误')).toBeInTheDocument();
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
});
it('externalError 清除后应显示内部验证错误', () => {
const { rerender } = render(
<TextInputArea
value="ab"
onChange={() => {}}
externalError="外部错误"
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
expect(screen.getByText('外部错误')).toBeInTheDocument();
rerender(
<TextInputArea
value="ab"
onChange={() => {}}
validateTrigger="onChange"
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
/>,
);
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'a' } });
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
});
});
describe('autoResize', () => {
it('autoResize=true 时设置 minRows/maxRows', () => {
const { container } = render(
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
);
const textarea = container.querySelector('textarea');
expect(textarea).toBeInTheDocument();
});
it('autoResize=false 时设置固定 rows', () => {
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
const textarea = container.querySelector('textarea');
expect(textarea).toBeInTheDocument();
});
});
describe('样式集成', () => {
it('应透传 className', () => {
const { container } = render(
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
);
expect(container.firstChild).toHaveClass('custom-class');
});
});
});
-118
View File
@@ -1,118 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ToolCard from '@/pages/Dashboard/ToolCard';
import { Clock } from 'lucide-react';
describe('ToolCard 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('渲染测试', () => {
it('应渲染标题和描述', () => {
render(
<ToolCard
title="测试工具"
description="这是一个测试工具"
colorKey="primary"
icon={Clock}
onNavigate={() => {}}
/>,
);
expect(screen.getByText('测试工具')).toBeInTheDocument();
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
});
it('无描述时仅渲染标题', () => {
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
expect(screen.getByText('仅标题')).toBeInTheDocument();
});
it('应渲染图标', () => {
const { container } = render(
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
});
it('提供快照内容时应渲染快照', () => {
render(
<ToolCard
title="带快照"
colorKey="primary"
icon={Clock}
onNavigate={() => {}}
snapshot={<div data-testid="snapshot"></div>}
/>,
);
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
});
it('未提供快照时不渲染快照区域', () => {
const { container } = render(
<ToolCard
title="无快照"
colorKey="primary"
icon={Clock}
onClick={() => {}}
onNavigate={function (): void {
throw new Error('Function not implemented.');
}}
/>,
);
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
});
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
const button = screen.getByRole('button', { name: /可聚焦/ });
expect(button).toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击时应调用 onClick', () => {
const handleClick = vi.fn();
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
const button = screen.getByRole('button', { name: /可点击/ });
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('按 Enter 键时应调用 onClick', async () => {
const handleClick = vi.fn();
render(
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
);
const button = screen.getByRole('button', { name: /键盘可触发/ });
await act(async () => {
button.focus();
await userEvent.keyboard('{Enter}');
});
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
describe('样式测试', () => {
it('应应用自定义颜色代码', () => {
const { container } = render(
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
});
});
});
-76
View File
@@ -1,76 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import type { PageType } from '@/types/storage';
import React from 'react';
import TopBar from '@/components/TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
const mockRouterValue = {
currentPage: 'dashboard' as PageType,
visiblePages: ['dashboard', 'timestamp'] as PageType[],
pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
syncNavigation: vi.fn(),
goBack: vi.fn(),
setVisiblePages: vi.fn(),
setPageOrder: vi.fn(),
};
vi.mock('@/providers/RouterProvider', () => ({
useRouter: () => mockRouterValue,
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
}));
describe('TopBar 组件', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactElement) => {
return render(
<ThemeModeProvider>
<RouterProvider>{ui}</RouterProvider>
</ThemeModeProvider>,
);
};
describe('渲染测试', () => {
it('不在 dashboard 时应渲染返回按钮', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />);
expect(screen.getByLabelText('返回')).toBeInTheDocument();
});
it('在 dashboard 上不应渲染返回按钮', () => {
mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar />);
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
});
});
describe('交互测试', () => {
it('点击返回按钮时应调用 goBack', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar />);
fireEvent.click(screen.getByLabelText('返回'));
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
});
});
});
-40
View File
@@ -1,40 +0,0 @@
# 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` 中定义。
-32
View File
@@ -1,32 +0,0 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
-48
View File
@@ -1,48 +0,0 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
-26
View File
@@ -1,26 +0,0 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
-101
View File
@@ -1,101 +0,0 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
-21
View File
@@ -1,21 +0,0 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
-19
View File
@@ -1,19 +0,0 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
-150
View File
@@ -1,150 +0,0 @@
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
-27
View File
@@ -1,27 +0,0 @@
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
-44
View File
@@ -1,44 +0,0 @@
# 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
-129
View File
@@ -1,129 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
FEATURES,
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '@/config/features';
describe('features', () => {
describe('FEATURES', () => {
it('should have 9 features defined', () => {
expect(FEATURES).toHaveLength(9);
});
it('should have all required properties for each feature', () => {
FEATURES.forEach((feature) => {
expect(feature).toHaveProperty('key');
expect(feature).toHaveProperty('labelKey');
expect(feature).toHaveProperty('descriptionKey');
expect(feature).toHaveProperty('defaultVisible');
expect(feature).toHaveProperty('components');
expect(typeof feature.key).toBe('string');
expect(typeof feature.labelKey).toBe('string');
expect(typeof feature.descriptionKey).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup');
expect(feature.components).toHaveProperty('sidepanel');
expect(feature.components).toHaveProperty('tab');
// Optional UI properties for non-hidden features
if (feature.key !== 'dashboard') {
expect(feature).toHaveProperty('icon');
expect(feature).toHaveProperty('themeColorKey');
expect(typeof feature.themeColorKey).toBe('string');
}
});
});
it('should have unique keys for each feature', () => {
const keys = FEATURES.map((f) => f.key);
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
});
describe('getFeatureByKey', () => {
it('should return dashboard feature', () => {
const feature = getFeatureByKey('dashboard');
expect(feature).toBeDefined();
expect(feature?.key).toBe('dashboard');
expect(feature?.labelKey).toBe('dashboard_title');
});
it('should return timestamp feature', () => {
const feature = getFeatureByKey('timestamp');
expect(feature).toBeDefined();
expect(feature?.key).toBe('timestamp');
expect(feature?.labelKey).toBe('timestamp_title');
expect(feature?.themeColorKey).toBeDefined();
});
it('should return storageCleaner feature', () => {
const feature = getFeatureByKey('storageCleaner');
expect(feature).toBeDefined();
expect(feature?.key).toBe('storageCleaner');
expect(feature?.labelKey).toBe('storageCleaner_title');
});
it('should return undefined for invalid key', () => {
const feature = getFeatureByKey('invalid' as any);
expect(feature).toBeUndefined();
});
});
describe('getDefaultVisibleFeatureKeys', () => {
it('should return only visible features', () => {
const visibleKeys = getDefaultVisibleFeatureKeys();
visibleKeys.forEach((key) => {
const feature = getFeatureByKey(key);
expect(feature?.defaultVisible).toBe(true);
});
});
it('should include dashboard, timestamp, storageCleaner, qrCode', () => {
const visibleKeys = getDefaultVisibleFeatureKeys();
expect(visibleKeys).toContain('dashboard');
expect(visibleKeys).toContain('timestamp');
expect(visibleKeys).toContain('storageCleaner');
expect(visibleKeys).toContain('qrCode');
});
});
describe('getAllFeatureKeys', () => {
it('should return all feature keys', () => {
const allKeys = getAllFeatureKeys();
expect(allKeys).toHaveLength(9);
expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner');
expect(allKeys).toContain('qrCode');
expect(allKeys).toContain('textStatistics');
expect(allKeys).toContain('jwt');
expect(allKeys).toContain('jsonDiff');
expect(allKeys).toContain('base64Converter');
expect(allKeys).toContain('rightClickRestorer');
});
});
describe('getDefaultPageOrder', () => {
it('should exclude dashboard from page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).not.toContain('dashboard');
});
it('should include timestamp, storageCleaner, qrCode in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toContain('timestamp');
expect(pageOrder).toContain('storageCleaner');
expect(pageOrder).toContain('qrCode');
});
it('should have 8 items in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(8);
});
});
});
-185
View File
@@ -1,185 +0,0 @@
import { type ComponentType, lazy } from 'react';
import type { LucideProps } from 'lucide-react';
import type { PageType } from '@/types/storage';
import {
Clock,
Database,
QrCode,
FileText,
Key,
GitCompareArrows,
ArrowLeftRight,
MousePointerClick,
} from 'lucide-react';
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
// 懒加载页面组件
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
const QrCodePage = lazy(() => import('@/pages/QrCode'));
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
export interface FeatureConfig {
key: PageType;
labelKey: string;
descriptionKey: string;
themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>;
defaultVisible: boolean;
components: {
popup: ComponentType;
sidepanel: ComponentType;
tab: ComponentType;
};
}
export const FEATURES: FeatureConfig[] = [
{
key: 'dashboard',
labelKey: 'dashboard_title',
descriptionKey: '',
defaultVisible: true,
components: {
popup: DashboardPage,
sidepanel: DashboardPage,
tab: DashboardPage,
},
},
{
key: 'timestamp',
labelKey: 'timestamp_title',
descriptionKey: 'timestamp_description',
themeColorKey: 'primary',
icon: Clock,
defaultVisible: true,
components: {
popup: TimestampPage,
sidepanel: TimestampPage,
tab: TimestampPage,
},
},
{
key: 'storageCleaner',
labelKey: 'storageCleaner_title',
descriptionKey: 'storageCleaner_description',
themeColorKey: 'warning',
icon: Database,
defaultVisible: true,
components: {
popup: StorageCleanerPage,
sidepanel: StorageCleanerPage,
tab: StorageCleanerPage,
},
},
{
key: 'qrCode',
labelKey: 'qrCode_title',
descriptionKey: 'qrCode_description',
themeColorKey: 'success',
icon: QrCode,
defaultVisible: true,
components: {
popup: QrCodePage,
sidepanel: QrCodePage,
tab: QrCodePage,
},
},
{
key: 'textStatistics',
labelKey: 'textStatistics_title',
descriptionKey: 'textStatistics_description',
themeColorKey: 'secondary',
icon: FileText,
defaultVisible: true,
components: {
popup: TextStatisticsPage,
sidepanel: TextStatisticsPage,
tab: TextStatisticsPage,
},
},
{
key: 'jwt',
labelKey: 'jwt_title',
descriptionKey: 'jwt_description',
themeColorKey: 'info',
icon: Key,
defaultVisible: true,
components: {
popup: JwtPage,
sidepanel: JwtPage,
tab: JwtPage,
},
},
{
key: 'jsonDiff',
labelKey: 'jsonDiff_title',
descriptionKey: 'jsonDiff_description',
themeColorKey: 'primary',
icon: GitCompareArrows,
defaultVisible: true,
components: {
popup: JsonToolsPage,
sidepanel: JsonToolsPage,
tab: JsonToolsPage,
},
},
{
key: 'base64Converter',
labelKey: 'base64Converter_title',
descriptionKey: 'base64Converter_description',
themeColorKey: 'info',
icon: ArrowLeftRight,
defaultVisible: true,
components: {
popup: Base64ConverterPage,
sidepanel: Base64ConverterPage,
tab: Base64ConverterPage,
},
},
{
key: 'rightClickRestorer',
labelKey: 'rightClickRestorer_title',
descriptionKey: 'rightClickRestorer_description',
themeColorKey: 'success',
icon: MousePointerClick,
defaultVisible: true,
components: {
popup: RightClickRestorerPage,
sidepanel: RightClickRestorerPage,
tab: RightClickRestorerPage,
},
},
];
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
return FEATURES.find((f) => f.key === key);
}
export function getDefaultVisibleFeatureKeys(): PageType[] {
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
}
export function getAllFeatureKeys(): PageType[] {
return FEATURES.map((f) => f.key);
}
export function getDefaultPageOrder(): PageType[] {
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
}
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
const pathname = window.location.pathname;
if (pathname.includes('sidepanel')) {
return 'sidepanel';
}
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
return 'tab';
}
return 'popup';
}
-51
View File
@@ -1,51 +0,0 @@
# 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` 的不同配置实现独立路由
@@ -1,144 +0,0 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
createAllContextMenus,
parseContextMenuClick,
CONTEXT_MENU_CONFIGS,
MAX_PAYLOAD_LENGTH,
} from '@/utils/contextMenu';
import { MessageAction } from '@/utils/messages';
describe('background 菜单注册与分流', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('菜单注册', () => {
it('应该调用 createAllContextMenus 创建所有菜单项', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length);
});
it('应该创建父级菜单 Testing Tools', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'testing-tools-parent',
title: 'Testing Tools',
}),
);
});
it('应该创建 JWT 解析子菜单', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'jwt',
title: '🔑 解析 JWT',
parentId: 'testing-tools-parent',
}),
);
});
it('应该创建网页链接转二维码子菜单', () => {
createAllContextMenus();
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'qrCode-page',
title: '🔗 网页链接转二维码',
contexts: ['page'],
}),
);
});
});
describe('菜单点击解析', () => {
const createMockOnClickData = (
overrides: Partial<chrome.contextMenus.OnClickData> = {},
): chrome.contextMenus.OnClickData => ({
menuItemId: 'test',
editable: false,
pageUrl: 'https://example.com',
...overrides,
});
it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => {
const info = createMockOnClickData({
menuItemId: 'jwt',
selectionText: 'test-token',
});
const result = parseContextMenuClick('jwt', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'jwt', payload: 'test-token' },
});
});
it('当没有 selectionText 和 srcUrl 时应返回错误', () => {
const info = createMockOnClickData({
menuItemId: 'unknown',
pageUrl: undefined,
});
const result = parseContextMenuClick('unknown', info);
expect(result).toEqual({
success: false,
error: '无法获取有效数据',
});
});
it('当文本超过最大长度限制时应截断', () => {
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
const info = createMockOnClickData({
menuItemId: 'textStatistics',
selectionText: longText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result.success).toBe(true);
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
});
it('当文本未超过最大长度限制时应保持原样', () => {
const shortText = 'short text';
const info = createMockOnClickData({
menuItemId: 'textStatistics',
selectionText: shortText,
});
const result = parseContextMenuClick('textStatistics', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'textStatistics', payload: 'short text' },
});
});
it('应该正确处理页面 URL 菜单点击', () => {
const info = createMockOnClickData({
menuItemId: 'storageCleaner',
pageUrl: 'https://example.com/page',
});
const result = parseContextMenuClick('storageCleaner', info);
expect(result).toEqual({
success: true,
data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' },
});
});
});
describe('消息类型定义', () => {
it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => {
expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked');
});
});
});
-86
View File
@@ -1,86 +0,0 @@
import '../../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
import { saveContextMenuData } from '@/utils/useContextMenuData';
import { mainWorldInjectionScript } from '@/utils/rightClickInjection';
export default defineBackground(() => {
// 1. 扩展初次安装或更新时,注册右键上下文菜单
browser.runtime.onInstalled.addListener(() => {
createAllContextMenus();
});
// 2. 右键点击路由
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
const result = parseContextMenuClick(info.menuItemId as string, info);
if (!result.success || !result.data) {
if (result.error) {
console.warn('[Context Menu Warning]', result.error);
}
return;
}
const { featureKey, payload } = result.data;
try {
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) {
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return;
}
} catch (err) {
console.debug('[Context Menu] Side panel pipeline is not available:', err);
}
await saveContextMenuData({ featureKey, payload });
try {
await browser.action.openPopup();
} catch (err) {
console.warn(
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err,
);
}
});
// 监听扩展图标点击事件,安全激活侧边栏
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
try {
await browser.sidePanel.open({ tabId: tab.id });
} catch (err) {
console.error('Failed to open side panel via extension action clicked:', err);
}
}
});
// 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: mainWorldInjectionScript,
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 };
}
});
});
-10
View File
@@ -1,10 +0,0 @@
import '../../.wxt/types/imports.d.ts';
import { initContextMenuHandler } from './content/contextMenuHandler';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_end',
main() {
initContextMenuHandler();
},
});
@@ -1,74 +0,0 @@
import type { ContextMenuClickedPayload } from '@/utils/messages';
import { MessageAction, onMessage } from '@/utils/messages';
import { getTextStats } from '@/utils/textStatistics';
import { getMessage } from '@/utils/chromeI18n';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function convertTimestamp(input: string): string {
const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp';
const num = Number(input.trim());
if (isNaN(num)) {
return invalidText;
}
// 1e12 判定毫秒级/秒级时间戳兼容
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
if (isNaN(d.getTime())) {
return invalidText;
}
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const minutes = String(d.getMinutes()).padStart(2, '0');
const seconds = String(d.getSeconds()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
}
let lastClickX = 0;
let lastClickY = 0;
document.addEventListener(
'contextmenu',
(e) => {
lastClickX = e.clientX;
lastClickY = e.clientY;
},
{ capture: true, passive: true },
);
export function initContextMenuHandler(): void {
const dismissPopover = (): void => {
hidePopover();
};
document.addEventListener('click', dismissPopover, { passive: true });
document.addEventListener('scroll', dismissPopover, { passive: true });
window.addEventListener('resize', dismissPopover, { passive: true });
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
switch (featureKey) {
case 'timestamp': {
const result = convertTimestamp(payload);
showTimestampResult(lastClickX, lastClickY, payload, result);
break;
}
case 'textStatistics': {
const stats = getTextStats(payload);
showTextStatsResult(lastClickX, lastClickY, payload, stats);
break;
}
default:
hidePopover();
break;
}
});
}
-275
View File
@@ -1,275 +0,0 @@
const POPOVER_ID = 'testing-tools-popover';
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
function injectStyles(): void {
if (document.getElementById(POPOVER_STYLE_ID)) return;
const style = document.createElement('style');
style.id = POPOVER_STYLE_ID;
style.textContent = `
#${POPOVER_ID} {
position: fixed;
z-index: 2147483647;
max-width: 400px;
min-width: 200px;
padding: 12px 16px;
background: #1a1a2e;
color: #e0e0e0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
line-height: 1.5;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
pointer-events: none;
}
#${POPOVER_ID}.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
#${POPOVER_ID} .popover-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
#${POPOVER_ID} .popover-title {
font-weight: 600;
font-size: 12px;
color: #a0a0b0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#${POPOVER_ID} .popover-close {
background: none;
border: none;
color: #808090;
cursor: pointer;
padding: 2px;
font-size: 16px;
line-height: 1;
}
#${POPOVER_ID} .popover-close:hover {
color: #e0e0e0;
}
#${POPOVER_ID} .popover-content {
word-break: break-all;
white-space: pre-wrap;
}
#${POPOVER_ID} .popover-label {
color: #808090;
font-size: 11px;
margin-bottom: 4px;
}
#${POPOVER_ID} .popover-value {
color: #ffffff;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 14px;
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
margin-bottom: 8px;
}
#${POPOVER_ID} .stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 4px;
}
#${POPOVER_ID} .stat-item {
padding: 6px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
#${POPOVER_ID} .stat-label {
font-size: 11px;
color: #808090;
}
#${POPOVER_ID} .stat-value {
font-size: 16px;
font-weight: 600;
color: #ffffff;
}
`;
document.head.appendChild(style);
}
function getOrCreatePopover(): HTMLElement {
let popover = document.getElementById(POPOVER_ID);
if (!popover) {
injectStyles();
popover = document.createElement('div');
popover.id = POPOVER_ID;
document.body.appendChild(popover);
}
return popover;
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
function positionPopover(popover: HTMLElement, x: number, y: number): void {
// 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽
const rect = popover.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点
let top = y + 8;
if (left + rect.width > viewportWidth - 16) {
left = viewportWidth - rect.width - 16;
}
if (left < 16) left = 16;
if (top + rect.height > viewportHeight - 16) {
top = y - rect.height - 8;
}
if (top < 16) top = 16;
popover.style.left = `${left}px`;
popover.style.top = `${top}px`;
}
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
export function showPopover(
x: number,
y: number,
contentHtml: string,
title?: string,
duration: number = 5000,
): void {
const popover = getOrCreatePopover();
popover.innerHTML = '';
if (title) {
const header = document.createElement('div');
header.className = 'popover-header';
const titleSpan = document.createElement('span');
titleSpan.className = 'popover-title';
titleSpan.textContent = title; // ✅ 强安全性护航
const closeBtn = document.createElement('button');
closeBtn.className = 'popover-close';
closeBtn.innerHTML = '&times;';
closeBtn.addEventListener('click', () => {
popover.classList.remove('visible');
});
header.appendChild(titleSpan);
header.appendChild(closeBtn);
popover.appendChild(header);
}
const contentContainer = document.createElement('div');
contentContainer.className = 'popover-content';
contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义
popover.appendChild(contentContainer);
// 提前移除激活类名,使 visibility: hidden 起效以供测量
popover.classList.remove('visible');
requestAnimationFrame(() => {
positionPopover(popover, x, y);
popover.classList.add('visible');
});
if (hideTimeout) clearTimeout(hideTimeout);
if (duration > 0) {
hideTimeout = setTimeout(() => {
hidePopover();
}, duration);
}
}
export function hidePopover(): void {
const popover = document.getElementById(POPOVER_ID);
if (popover) {
popover.classList.remove('visible');
}
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
}
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
// 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗
const cleanTimestamp = escapeHtml(timestamp);
const cleanResult = escapeHtml(result);
const content = `
<div class="popover-label">输入时间戳</div>
<div class="popover-value">${cleanTimestamp}</div>
<div class="popover-label">转换结果</div>
<div class="popover-value">${cleanResult}</div>
`;
showPopover(x, y, content, '⏰ 时间戳转换');
}
export function showTextStatsResult(
x: number,
y: number,
text: string,
stats: { characters: number; words: number; lines: number; bytes: number },
): void {
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
// 对选中的脏文本先进行严格转义
const cleanText = escapeHtml(truncatedText);
const content = `
<div class="popover-label">选中文本</div>
<div class="popover-value">${cleanText}</div>
<div class="stat-grid">
<div class="stat-item">
<div class="stat-label">字符</div>
<div class="stat-value">${stats.characters}</div>
</div>
<div class="stat-item">
<div class="stat-label">单词</div>
<div class="stat-value">${stats.words}</div>
</div>
<div class="stat-item">
<div class="stat-label">行数</div>
<div class="stat-value">${stats.lines}</div>
</div>
<div class="stat-item">
<div class="stat-label">字节</div>
<div class="stat-value">${stats.bytes}</div>
</div>
</div>
`;
showPopover(x, y, content, '📊 文本统计');
}
-36
View File
@@ -1,36 +0,0 @@
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { getEntryPointType } from '@/config/features';
export default function App() {
const entryType = getEntryPointType();
const routerConfig =
entryType === 'tab'
? {
syncKey: 'app/tabRoute' as const,
visiblePagesKey: 'app/tabVisiblePages' as const,
pageOrderKey: 'app/tabPageOrder' as const,
}
: {
syncKey: 'app/popupRoute' as const,
visiblePagesKey: 'app/popupVisiblePages' as const,
pageOrderKey: 'app/popupPageOrder' as const,
};
return (
<RouterProvider
syncKey={routerConfig.syncKey}
visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey={routerConfig.pageOrderKey}
>
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</RouterProvider>
);
}
-36
View File
@@ -1,36 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Tools - 标签页</title>
<meta name="manifest.type" content="browser_action" />
<style>
/* Force initial popup size before React hydration */
html, body {
width: 400px;
height: 600px;
margin: 0;
padding: 0;
overflow: hidden;
background-color: hsl(var(--background));
}
/* Ensure full size for the root container */
#root {
width: 100%;
height: 100%;
}
/* If opened in a tab (mode=tab), reset the fixed size */
@media screen and (min-width: 600px) {
html, body {
width: 100%;
height: 100%;
}
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import ReactDOM from 'react-dom/client';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import App from './App.tsx';
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<App />
</AppRoot>,
);
@@ -1,79 +0,0 @@
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
let isRestored = false;
/* ======================== 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,
);
}
/* ======================== 激活保护 ======================== */
async function activateProtection(): Promise<void> {
if (isRestored) return;
await injectMainWorldScript();
// 再次检查,防止并发调用导致重复注册
if (isRestored) return;
installEventIntercepts();
isRestored = true;
}
/* ======================== 消息通信 ======================== */
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() {
initMessaging();
},
});
-26
View File
@@ -1,26 +0,0 @@
import { useEffect } from 'react';
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { MessageAction, sendMessage } from '@/utils/messages';
export default function App() {
useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
};
}, []);
return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</RouterProvider>
);
}
-92
View File
@@ -1,92 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
color-scheme: dark;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
border-color: hsl(var(--border));
}
html {
scrollbar-width: none;
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
input,
textarea,
select {
color: hsl(var(--foreground));
}
}
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
display: none;
}
-26
View File
@@ -1,26 +0,0 @@
# 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()`,而非手动拼接字符串。
-6
View File
@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -1,253 +0,0 @@
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n';
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';
interface Base64ConverterSectionProps {
mode: 'file' | 'image';
}
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
const { t } = useI18n('base64Converter');
const [direction, setDirection] = useStorageState(
`base64Converter/${mode}Mode/direction`,
'encode',
isValidDirection,
);
const {
result,
info,
isLoading,
isDragging,
setIsDragging,
fileInputRef,
encodeError,
decodeInput,
setDecodeInput,
decoded,
decodeError,
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
maxFileSizeStr,
} = useBase64Converter({ mode });
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 px-2">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
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',
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={mode === 'image' ? 'image/*' : undefined}
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 w-full">
{mode === 'image' && 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>
)}
<Upload className={cn('w-8 h-8 text-primary')} />
<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">
{mode === 'image' ? (
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
) : (
<Upload className="w-8 h-8 text-muted-foreground/60" />
)}
<span className="text-xs font-bold text-foreground/80">
{mode === 'image' ? t('clickOrDropToImage') : t('clickOrDropToFile')}
</span>
<span className="text-[10px] font-medium text-muted-foreground/60">
{t('maxFileSize', { max: maxFileSizeStr })}
</span>
{mode === 'image' && (
<span className="text-[10px] font-medium text-muted-foreground/50">
{t('supportedFormats')}
</span>
)}
</div>
)}
</div>
{encodeError && (
<div 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={mode === 'image' ? t('decodedImageOutput') : t('decodedFileOutput')}
mimeType={decoded.mimeType}
blobSize={decoded.blob.size}
fileName={decodedFileName}
onFileNameChange={setCustomFileName}
onDownload={handleDownload}
>
{mode === 'image' && (
<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>
);
}
-162
View File
@@ -1,162 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import TextInputArea from '@/components/TextInputArea';
import { useI18n } from '@/utils/chromeI18n';
import CopyButton from '@/components/CopyButton';
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useContextMenuData } from '@/utils/useContextMenuData';
import { Button } from '@/components/ui/button';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
'Invalid Base64 string': 'invalidBase64',
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
'binaryDataDetected',
};
interface TextModeProps {
onSwitchToImageMode?: () => void;
}
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { t } = useI18n('base64Converter');
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 200);
return () => clearTimeout(handle);
}, [input]);
const handleContextMenuData = useCallback((payload: string) => {
setInput(payload);
setDebouncedInput(payload);
setDirection('decode');
}, []);
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null };
try {
if (direction === 'encode') {
const result = textToBase64(debouncedInput);
return { output: result.output, error: null };
} else {
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
}
} catch (e) {
const message = e instanceof Error ? e.message : '';
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
return {
output: '',
error: i18nKey ? t(i18nKey) : message || t('conversionFailed'),
};
}
}, [debouncedInput, direction, t]);
const output = conversionPipeline.output;
const error = conversionPipeline.error;
const placeholder =
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput');
const showImageHint = useMemo(
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
[direction, input],
);
const handleDirectionChange = (value: 'encode' | 'decode') => {
if (value === direction) return;
setDirection(value);
setInput('');
setDebouncedInput('');
};
const handleClear = () => {
setInput('');
setDebouncedInput('');
};
return (
<div className="w-full flex flex-col space-y-4 px-2">
{/* 受控方向切流中枢 */}
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
<SwitchButtonGroup
value={direction}
options={[
{ value: 'encode', label: t('encode') },
{ value: 'decode', label: t('decode') },
]}
onChange={handleDirectionChange}
size="small"
/>
</div>
{/* 高性能受控文本输入端 */}
<TextInputArea
placeholder={placeholder}
value={input}
onChange={setInput}
externalError={error || undefined}
showClear={true}
allowCopy={true}
minRows={5}
maxRows={10}
onClear={handleClear}
/>
{/* 图片 URI 类型劫持警告引导区:
- 💡 修复点:彻底废除原生亮色硬编码 hover:bg-blue-100 类名,
- 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。
*/}
{showImageHint && (
<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">
{t('imageDataUriHint')}
</span>
<Button
type="button"
variant="ghost"
size="sm"
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 px-2.5"
>
{t('switchToImageMode')}
</Button>
</div>
)}
{/* 5. 编码/解码核心数据承载流卡片 */}
{output && (
<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">
{outputLabel}
</span>
<CopyButton
text={output}
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
<TextInputArea
readOnly
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
showClear={false}
minRows={4}
/>
</div>
)}
</div>
);
}
@@ -1,190 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import TextMode from '../TextMode';
// Mock 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>,
}));
describe('TextMode', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
vi.useRealTimers();
});
it('应该渲染编码/解码切换按钮', () => {
render(<TextMode />);
expect(screen.getByText('编码')).toBeInTheDocument();
expect(screen.getByText('解码')).toBeInTheDocument();
});
it('应该渲染输入框和转换按钮', () => {
render(<TextMode />);
expect(screen.getByPlaceholderText('输入需要编码为 Base64 的文本...')).toBeInTheDocument();
});
it('应该将文本编码为 Base64', async () => {
render(<TextMode />);
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
vi.advanceTimersByTime(200);
});
await waitFor(() => {
expect(screen.getByText('Base64 编码结果')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
it('应该解码 Base64 文本', async () => {
render(<TextMode />);
// 切换到解码模式
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
act(() => {
vi.advanceTimersByTime(200);
});
await waitFor(() => {
expect(screen.getByText('解码文本结果')).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument();
});
it('应该对无效 Base64 显示错误', async () => {
render(<TextMode />);
// 切换到解码模式
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.change(input, { target: { value: 'invalid!!!' } });
act(() => {
vi.advanceTimersByTime(200);
});
await waitFor(() => {
expect(screen.getByText('Base64 字符串无效')).toBeInTheDocument();
});
});
it('切换方向时应该清除输出', async () => {
render(<TextMode />);
// 先编码
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
vi.advanceTimersByTime(200);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
// 切换方向
fireEvent.click(screen.getByText('解码'));
// 输出应该被清除
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
});
});
it('点击清除按钮应该清空所有内容', async () => {
render(<TextMode />);
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
fireEvent.change(input, { target: { value: 'Hello' } });
act(() => {
vi.advanceTimersByTime(200);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
expect(input).toHaveValue('');
});
});
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
render(<TextMode />);
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
});
expect(
screen.getByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
).toBeInTheDocument();
expect(screen.getByText('切换到图像模式')).toBeInTheDocument();
});
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
render(<TextMode />);
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
expect(
screen.queryByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
).not.toBeInTheDocument();
});
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
const onSwitch = vi.fn();
render(<TextMode onSwitchToImageMode={onSwitch} />);
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.change(input, {
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
});
fireEvent.click(screen.getByText('切换到图像模式'));
expect(onSwitch).toHaveBeenCalledTimes(1);
});
it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
render(<TextMode />);
fireEvent.click(screen.getByText('解码'));
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
act(() => {
vi.advanceTimersByTime(200);
});
await waitFor(() => {
expect(
screen.getByText('输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。'),
).toBeInTheDocument();
});
});
});
@@ -1,67 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import Base64ConverterPage from '../index';
// Mock getEntryPointType
vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>();
return {
...actual,
getEntryPointType: () => 'sidepanel',
};
});
// Mock 子组件
vi.mock('../TextMode', () => ({
default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
<div data-testid="text-mode">
TextMode
{onSwitchToImageMode && <button onClick={onSwitchToImageMode}>switchToImage</button>}
</div>
),
}));
vi.mock('../Base64ConverterSection', () => ({
default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>,
}));
const waitForStorageInit = () =>
act(async () => {
await Promise.resolve();
});
describe('Base64ConverterPage', () => {
it('应该默认渲染文本模式', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
expect(screen.getByTestId('text-mode')).toBeInTheDocument();
});
it('应该渲染模式切换按钮', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
expect(screen.getByText('文本')).toBeInTheDocument();
expect(screen.getByText('文件')).toBeInTheDocument();
expect(screen.getByText('图像')).toBeInTheDocument();
});
it('切换到文件模式应该渲染 FileMode', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
fireEvent.click(screen.getByText('文件'));
await waitFor(() => {
expect(screen.getByTestId('file-mode')).toBeInTheDocument();
});
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
});
it('切换到图像模式应该渲染 ImageMode', async () => {
render(<Base64ConverterPage />);
await waitForStorageInit();
fireEvent.click(screen.getByText('图像'));
await waitFor(() => {
expect(screen.getByTestId('image-mode')).toBeInTheDocument();
});
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
});
});
-43
View File
@@ -1,43 +0,0 @@
import { useI18n } from '@/utils/chromeI18n';
import { useStorageState } from '@/utils/useStorageState';
import type { Base64ConverterPageMode } from '@/types/storage';
import TextMode from './TextMode';
import Base64ConverterSection from './Base64ConverterSection';
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image'];
const isValidPageMode = (val: unknown): val is Base64ConverterPageMode =>
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
type PageMode = Base64ConverterPageMode;
export default function Index() {
const { t } = useI18n('base64Converter');
const [pageMode, setPageMode] = useStorageState(
'base64Converter/pageMode',
'text',
isValidPageMode,
);
return (
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none">
<SwitchButtonGroup
value={pageMode}
options={[
{ value: 'text', label: t('base64Converter:textMode') },
{ value: 'file', label: t('base64Converter:fileMode') },
{ value: 'image', label: t('base64Converter:imageMode') },
]}
onChange={(value: PageMode) => setPageMode(value)}
size="small"
className="w-full sm:w-auto"
/>
<div className="w-full pt-1">
{pageMode === 'text' && <TextMode onSwitchToImageMode={() => setPageMode('image')} />}
{pageMode === 'file' && <Base64ConverterSection mode="file" />}
{pageMode === 'image' && <Base64ConverterSection mode="image" />}
</div>
</div>
);
}
@@ -1,153 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '@/utils/chromeI18n';
import type { FileToBase64Result } from '@/utils/base64Converter';
import {
base64ToBlob,
fileToBase64,
isFileSizeValid,
isSupportedImageExtension,
isSupportedImageType,
MAX_FILE_SIZE,
} from '@/utils/base64Converter';
interface FileInfo {
name: string;
size: number;
type: string;
}
interface UseBase64ConverterProps {
mode: 'file' | 'image';
}
export function useBase64Converter({ mode }: UseBase64ConverterProps) {
const { t } = useI18n('base64Converter');
const [result, setResult] = useState<FileToBase64Result | null>(null);
const [info, setInfo] = useState<FileInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const cancelRef = useRef(false);
const [decodeInput, setDecodeInput] = useState('');
const [debouncedDecodeInput, setDebouncedDecodeInput] = useState('');
const [encodeError, setEncodeError] = useState<string | null>(null);
const [customFileName, setCustomFileName] = useState('');
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedDecodeInput(decodeInput);
}, 250);
return () => clearTimeout(handle);
}, [decodeInput]);
const resetAll = useCallback(() => {
cancelRef.current = true;
setResult(null);
setInfo(null);
setIsLoading(false);
setDecodeInput('');
setDebouncedDecodeInput('');
setCustomFileName('');
setEncodeError(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}, []);
const handleFileSelect = useCallback(
async (file: File) => {
cancelRef.current = false;
setEncodeError(null);
setResult(null);
setInfo(null);
if (!isFileSizeValid(file.size)) {
setEncodeError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` }));
return;
}
if (
mode === 'image' &&
!isSupportedImageType(file.type) &&
!isSupportedImageExtension(file.name)
) {
setEncodeError(t('unsupportedImageType'));
return;
}
setInfo({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
});
setIsLoading(true);
try {
const res = await fileToBase64(file);
if (!cancelRef.current) setResult(res);
} catch (e) {
if (!cancelRef.current) {
setEncodeError(e instanceof Error ? e.message : t('conversionFailed'));
}
} finally {
if (!cancelRef.current) setIsLoading(false);
}
},
[mode, t],
);
const safeFileSelect = useCallback(
(file: File) => {
handleFileSelect(file).catch((err) => {
console.error(`Base64 [${mode}] pipeline crash:`, err);
});
},
[handleFileSelect, mode],
);
const decodePipeline = useMemo(() => {
const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim();
if (!cleanedInput) return { decoded: null, error: null };
try {
const res = base64ToBlob(cleanedInput);
return { decoded: res, error: null };
} catch (e) {
const message = e instanceof Error ? e.message : '';
return {
decoded: null,
error: message === 'Invalid Base64 string' ? t('invalidBase64') : t('conversionFailed'),
};
}
}, [debouncedDecodeInput, t]);
const decoded = decodePipeline.decoded;
const decodeError = decodePipeline.error;
const decodedFileName = useMemo(() => {
if (customFileName) return customFileName;
if (decoded) return `decoded${decoded.suggestedExtension}`;
return '';
}, [customFileName, decoded]);
const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`;
return {
result,
info,
isLoading,
isDragging,
setIsDragging,
fileInputRef,
encodeError,
decodeInput,
setDecodeInput,
decoded,
decodeError,
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
maxFileSizeStr,
};
}

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