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
43 changed files with 706 additions and 12269 deletions
+1 -6
View File
@@ -8,12 +8,7 @@
"Bash(npm run:*)", "Bash(npm run:*)",
"Bash(git show-ref:*)", "Bash(git show-ref:*)",
"Bash(git checkout:*)", "Bash(git checkout:*)",
"mcp__plugin_playwright_playwright__browser_navigate", "mcp__plugin_playwright_playwright__browser_navigate"
"Skill(code-review:code-review)",
"Bash(grep -E \"\\\\.\\(md|txt\\)$\")",
"Bash(pkill -f \"wxt\")",
"Bash(python3 -m json.tool)",
"Bash(git restore:*)"
] ]
} }
} }
+121 -133
View File
@@ -2,182 +2,170 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Quick Start ## 项目概述
A browser extension built with the WXT framework, providing timestamp conversion and storage cleaning tools. 这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
### Essential Commands ## 核心命令
| Command | Purpose | ### 开发相关
| ----------------------- | ------------------------------------------------------ |
| `npm install` | Install dependencies (runs `wxt prepare` post-install) |
| `npm run dev` | Start development mode for Chrome |
| `npm run dev:firefox` | Start development mode for Firefox |
| `npm run build` | Build production version for Chrome |
| `npm run build:firefox` | Build production version for Firefox |
| `npm run zip` | Package Chrome extension |
| `npm run zip:firefox` | Package Firefox extension |
| `npm run compile` | TypeScript type checking (no file generation) |
| `npm run lint` | Run ESLint with zero warnings allowed |
### Setup - `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 检查
- Dependencies install automatically runs `wxt prepare` via postinstall hook ### 依赖与准备
- Husky Git hooks are initialized via `prepare` script
## Architecture Overview - `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
### Tech Stack ### CI/CD
- **Framework**: WXT (Web Extension Toolkit) - GitHub Actions 配置: `.github/workflows/node.js.yml`
- **Frontend**: React 19 + TypeScript - 在 main 分支推送或 PR 时触发
- **UI Library**: Material UI (MUI) - 使用 Node.js 22.x 运行 build
- **Date Handling**: dayjs (with UTC and timezone plugins) - 测试命令当前被注释(项目暂无测试)
- **Communication**: @webext-core/messaging
- **Storage**: Chrome Storage API with type-safe wrapper
### Directory Structure ## 项目架构
### 技术栈
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI (MUI)
- **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging
### 目录结构
``` ```
entrypoints/ # Browser extension entry points ├── entrypoints/ # 浏览器扩展入口点
├── background.ts # Background script (injects content scripts) ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
├── content.ts # Content script (injected into pages) ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
├── popup/ # Extension popup interface ├── popup/ # 扩展弹窗界面
│ ├── App.tsx # Popup main application (handles routing) │ ├── App.tsx # 弹窗主应用
│ ├── main.tsx # Popup entry point │ ├── main.tsx # 弹窗入口
│ ├── index.html # Popup HTML │ ├── index.html # 弹窗 HTML
│ └── pages/ # Popup pages │ └── pages/ # 弹窗页面
── TimestampPage.tsx # Timestamp conversion ── TimestampPage.tsx # 时间戳转换页面(核心功能)
└── StorageCleanerPage.tsx # Storage cleaning └── options/ # 选项页面(当前为静态 HTML)
└── options/ # Options page (static HTML) │ └── index.html # 选项页 HTML
utils/ # Utility functions ├── utils/ # 工具函数
types/ # TypeScript type definitions │ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
public/ # Static assets │ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging
├── types/ # 类型定义
│ └── storage.d.ts # StorageSchema 类型定义
├── constants/ # 常量定义(当前为空)
└── public/ # 静态资源
``` ```
### Extension Entry Points ### 核心功能
- **Background Script**: Listens for install/update events, injects content scripts into valid tabs #### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
- **Content Script**: Matches all URLs (`<all_urls>`), runs at document start (currently placeholder)
- **Popup**: Main interface with tab-based navigation between timestamp conversion and storage cleaning
- **Options Page**: Static HTML page, can be extended as settings interface
## Core Features - 实时显示当前时间戳(毫秒/秒可切换)
- 时间戳 → 日期时间转换
- 日期时间 → 时间戳转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制功能
- 输入验证和错误提示
### Timestamp Conversion Tool (`entrypoints/popup/pages/TimestampPage.tsx`) ### 扩展入口点
- Real-time current timestamp display (milliseconds/seconds toggle) - **后台脚本** (`entrypoints/background.ts`):
- Timestamp ↔ date/time conversion - 监听扩展安装/更新事件
- Support for multiple timezones (Asia/Shanghai, America/New_York, Europe/London) - 自动向所有有效标签页注入内容脚本
- One-click copy functionality - 过滤受限协议(chrome://, about:// 等)
- Input validation and error handling
### Storage Cleaning Tool (`entrypoints/popup/pages/StorageCleanerPage.tsx`) - **内容脚本** (`entrypoints/content.ts`):
- 匹配所有 URL (`<all_urls>`)
- 在文档开始时运行
- 当前为占位符,无实际逻辑
- Automatically reads current domain - **弹窗** (`entrypoints/popup/`):
- Cleans multiple storage types: localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, Service Workers - 主入口显示 TimestampPage
- User-selectable storage types (all selected by default) - 提供时间戳转换的完整功能
- Confirmation dialog to prevent accidental cleaning
- Cleaning result statistics display
- Auto-refresh page after cleaning option
- User preferences persistence
### Data Storage - **选项页** (`entrypoints/options/`):
- 当前为静态 HTML 页面
- 可扩展为设置界面
Uses Chrome Storage API with type-safe wrapper (`utils/chromeStorage.ts`): ### 数据存储
- **Storage Schema** (`types/storage.d.ts`): Interface-based type definitions 使用 Chrome Storage API 进行持久化存储:
- **Current storage keys**:
- `app/currentRoute`: Current active page route (default: 'timestamp')
- `app/visiblePages`: List of visible pages (default: ['timestamp', 'storageCleaner'])
- `app/lastRoute`: Last accessed route (legacy)
- `app/theme`: Theme settings
- `storageCleaner/preferences`: Storage cleaner preferences (autoRefresh, selectedTypes)
## Development Workflow - 类型安全的封装 (`utils/chromeStorage.ts`)
- 基于接口定义的 Schema (`types/storage.d.ts`)
- 当前支持的存储键:
- `app/lastRoute`: 上次访问的路由
- `app/theme`: 主题设置
### Browser Compatibility ### 消息通信
- Supports Chrome and Firefox browsers 使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
- Uses WXT framework to abstract browser differences
### Code Quality - 定义在 `utils/messages.tsx`
- 当前 ProtocolMap 为空(预留接口)
- **ESLint**: Zero warnings enforced (`npm run lint`) ### 关键配置文件
- **Husky**: Git hook management
- **lint-staged**: Ensures staged files comply (ESLint + TypeScript + Prettier)
- **Prettier**: Code formatting (100 char line width, 2 space indent, single quotes, trailing comma)
### TypeScript Configuration #### wxt.config.ts
- Strict mode enabled (`strict: true`) - 启用 React 模块 (`@wxt-dev/module-react`)
- `noImplicitAny` set to `false` (allows implicit any) - 配置 manifest 权限和 host_permissions
- Unused variables/parameters cause errors (`noUnusedLocals`, `noUnusedParameters`) - 使用 Terser 压缩(强制 ASCII 编码)
- Module resolution mode: Bundler - 配置图标和选项页
- Path alias: `@/*` maps to project root
- Excludes test files from type checking
### Path Aliases #### manifest 权限
- Use `@/` prefix for project-relative imports (e.g., `@/utils/chromeStorage`)
- Configured in `tsconfig.json` paths
## Configuration & Implementation
### `wxt.config.ts`
- Enables React module (`@wxt-dev/module-react`)
- Configures manifest permissions and host_permissions
- Uses Terser compression (forces ASCII encoding)
- Configures icons and options page
### Manifest Permissions
```typescript ```typescript
permissions: [ permissions: [
'storage', // Chrome Storage 'storage', // Chrome Storage
'unlimitedStorage', // Unlimited storage 'unlimitedStorage', // 无限制存储
'clipboardWrite', // Clipboard write (copy functionality) 'clipboardWrite', // 剪贴板写入(复制功能)
'activeTab', // Current tab access 'activeTab', // 当前标签页访问
'scripting', // Script injection 'scripting', // 脚本注入
'tabs', // Tab management 'tabs', // 标签页管理
'debugger', // Debugger permissions 'debugger', // 调试器权限
'cookies', // Cookies access (added for storage cleaning)
], ],
host_permissions: ['<all_urls>'] // Access all websites host_permissions: ['<all_urls>'] // 访问所有网站
``` ```
### Storage Cleaning Implementation Details ## 开发注意事项
- **Cookies**: Uses `chrome.cookies` API directly in extension context ### 浏览器兼容性
- **Other storage types**: Uses `chrome.scripting.executeScript` to inject cleaning scripts into page context
- **Restricted page filtering**: chrome://, about://, edge://, view-source://, file://, data://
- **IndexedDB**: Uses `indexedDB.databases()` to get database list, handles `onblocked` events
- **Service Workers**: Unregisters to prevent re-caching
- **Cache Storage**: Uses `caches` API to clear all caches
### Messaging System - 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
- Uses `@webext-core/messaging` library for type-safe extension communication ### 代码质量
- Defined in `utils/messages.tsx`
- Current ProtocolMap is empty (reserved for future use)
## CI/CD & Project Context - 使用 ESLint 进行代码检查(零警告)
- Husky 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier
- Prettier 用于代码格式化
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
### GitHub Actions Workflow (`.github/workflows/node.js.yml`) ### TypeScript 配置
- Triggers on push to main branch or pull requests - 严格模式开启(`strict: true`
- Uses Node.js 20.x and 22.x for multi-version testing - `noImplicitAny` 设置为 `false`(允许隐式 any
- Runs ESLint, TypeScript compilation, and build steps - 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`
- Test commands are currently commented (project has no tests) - 模块解析模式:Bundler
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
### Project History ### 项目历史
Recent refactoring streamlined the project: 近期重构(根据 git 历史):
- Removed recording and playback functionality - 移除了录制回放功能
- Removed test pages - 移除了测试页面
- Streamlined to single-page timestamp tool - 精简为单页面时间戳工具
- Renamed storage utility class to storageUtil - storage 工具类重命名为 storageUtil
- Added storage cleaning functionality with persistent preferences
- Added route persistence for popup navigation
-32
View File
@@ -1,32 +0,0 @@
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
export type ButtonProps = MuiButtonProps;
export function Button({ sx = [], ...props }: ButtonProps) {
return (
<MuiButton
disableElevation
disableRipple
{...props}
sx={[
{
py: 1.6,
borderRadius: 4,
fontSize: '1rem',
fontWeight: 600,
textTransform: 'none',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
transform: 'translateY(-1px)',
},
'&:active': {
transform: 'translateY(0)',
},
},
...(Array.isArray(sx) ? sx : [sx]),
]}
/>
);
}
export default Button;
-178
View File
@@ -1,178 +0,0 @@
import { useState } from 'react';
import { Snackbar, Alert, type SxProps, type Theme, alpha } from '@mui/material';
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
export interface GlobalSnackbarProps {
/** 消息内容 */
message: string;
/** 是否显示 */
open: boolean;
/** 关闭回调 */
onClose: () => void;
/** 消息级别:影响颜色 */
severity?: SnackbarSeverity;
/** 自动隐藏时间,毫秒,0 不自动关闭 */
autoHideDuration?: number;
/** 弹出位置 */
anchorOrigin?: {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right';
};
/** 是否使用 Alert 包裹(false 则使用原生 Snackbar message */
showAlert?: boolean;
/** Alert 是否隐藏图标 */
hideIcon?: boolean;
/** 自定义样式,透传给 Snackbar */
sx?: SxProps<Theme>;
/** 自定义样式,透传给 Alert(仅当 showAlert=true 时生效) */
alertSx?: SxProps<Theme>;
}
export interface SnackbarOptions {
severity?: SnackbarSeverity;
autoHideDuration?: number;
hideIcon?: boolean;
showAlert?: boolean;
}
export interface UseSnackbarResult {
snackbarProps: GlobalSnackbarProps;
showMessage: (message: string, options?: SnackbarOptions) => void;
closeMessage: () => void;
}
const defaultProps: Required<
Pick<
GlobalSnackbarProps,
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
>
> = {
severity: 'info',
autoHideDuration: 2000,
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
showAlert: true,
hideIcon: false,
};
export function GlobalSnackbar({
message,
open,
onClose,
severity = defaultProps.severity,
autoHideDuration = defaultProps.autoHideDuration,
anchorOrigin = defaultProps.anchorOrigin,
showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon,
sx,
alertSx,
}: GlobalSnackbarProps) {
// 共享的固定定位样式
const fixedSx: SxProps<Theme> = {
position: 'fixed',
bottom: '24px !important', // 固定在视口底部
left: '50% !important',
transform: 'translateX(-50%) !important',
zIndex: (theme) => theme.zIndex.tooltip + 100,
maxWidth: '90%',
width: 'max-content',
};
if (showAlert) {
return (
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
onClose={onClose}
anchorOrigin={anchorOrigin}
disableWindowBlurListener
sx={[fixedSx, ...(Array.isArray(sx) ? sx : [sx])]}
>
<Alert
severity={severity}
variant="filled"
icon={hideIcon ? false : undefined}
sx={[
{
borderRadius: '50px',
px: 2.5,
py: 0.2,
minWidth: '140px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 800,
fontSize: '0.75rem',
letterSpacing: '0.02em',
backgroundImage: 'none',
boxShadow: (theme: Theme) => `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
'& .MuiAlert-icon': {
mr: 0.5,
fontSize: '1.1rem',
color: '#fff'
},
'& .MuiAlert-message': {
color: '#fff',
padding: '6px 0',
textAlign: 'center'
}
},
...(Array.isArray(alertSx) ? alertSx : [alertSx]),
]}
>
{message}
</Alert>
</Snackbar>
);
}
return (
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
onClose={onClose}
anchorOrigin={anchorOrigin}
message={message}
sx={[fixedSx, ...(Array.isArray(sx) ? sx : [sx])]}
/>
);
}
export function useSnackbar(initialOptions?: SnackbarOptions): UseSnackbarResult {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
setMessage(newMessage);
setOptions({ ...initialOptions, ...newOptions });
setOpen(true);
};
const closeMessage = () => {
setOpen(false);
};
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === 'clickaway') return;
closeMessage();
};
const snackbarProps: GlobalSnackbarProps = {
message,
open,
onClose: handleClose,
severity: options.severity,
autoHideDuration: options.autoHideDuration,
hideIcon: options.hideIcon,
};
return {
snackbarProps,
showMessage,
closeMessage,
};
}
export default GlobalSnackbar;
-35
View File
@@ -1,35 +0,0 @@
import { Box } from '@mui/material';
import { ROUTES } from '@/config/routes';
import { useRouter } from '@/providers/RouterProvider';
import { useMemo } from 'react';
export default function RouterContainer() {
const { currentPage, isLoaded } = useRouter();
const animationClass = useMemo(() => {
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
}, [currentPage]);
if (!isLoaded) {
return <div className="app">Loading...</div>;
}
const currentRoute = ROUTES.find(route => route.key === currentPage);
return (
<Box
key={currentPage} // Trigger animation on navigation
className={animationClass}
sx={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
scrollbarGutter: 'stable',
display: 'flex',
flexDirection: 'column',
}}
>
{currentRoute && <currentRoute.component />}
</Box>
);
}
-107
View File
@@ -1,107 +0,0 @@
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Typography,
Box,
Chip,
} from '@mui/material';
import type { StorageCleanerOptions } from '@/types/storage';
import Button from '@/components/Button';
export interface StorageCleanerConfirmProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
options: StorageCleanerOptions;
}
export function StorageCleanerConfirm({
open,
onClose,
onConfirm,
options,
}: StorageCleanerConfirmProps) {
const selectedOptions = Object.entries(options)
.filter(([_, value]) => value)
.map(([key, _]) => key);
return (
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="xs"
slotProps={{
paper: {
sx: {
borderRadius: 5,
backgroundImage: 'none',
boxShadow: '0 24px 48px -12px rgba(0,0,0,0.15)',
p: 1
},
},
}}
>
<DialogTitle sx={{ textAlign: 'center', pt: 3, pb: 1, fontWeight: 900, letterSpacing: '-0.5px', fontSize: '1.25rem' }}>
</DialogTitle>
<DialogContent sx={{ textAlign: 'center', pb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, fontWeight: 500 }}>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, justifyContent: 'center', mb: 3 }}>
{selectedOptions.map((opt) => (
<Chip
key={opt}
label={opt}
size="small"
sx={{
bgcolor: 'grey.50',
fontWeight: 600,
color: 'text.secondary',
fontSize: '0.7rem',
border: '1px solid',
borderColor: 'grey.200'
}}
/>
))}
</Box>
<Typography variant="caption" sx={{ color: '#ff9800', fontWeight: 700, bgcolor: '#fff4e5', px: 1.5, py: 0.5, borderRadius: 2 }}>
</Typography>
</DialogContent>
<DialogActions sx={{ p: 2.5, gap: 1.5 }}>
<Button
variant="text"
onClick={onClose}
fullWidth
sx={{ fontWeight: 700, color: 'text.secondary', borderRadius: 3 }}
>
</Button>
<Button
variant="contained"
onClick={onConfirm}
fullWidth
sx={{
bgcolor: '#ff9800',
'&:hover': { bgcolor: '#f57c00' },
fontWeight: 800,
borderRadius: 3,
boxShadow: 'none'
}}
>
</Button>
</DialogActions>
</Dialog>
);
}
export default StorageCleanerConfirm;
-114
View File
@@ -1,114 +0,0 @@
import { Box, Typography, Stack } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import React from 'react';
interface ToolCardProps {
title: string;
description?: string;
snapshot?: React.ReactNode;
colorCode: string;
icon: React.ReactNode;
onClick: () => void;
hasAI?: boolean;
}
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI }: ToolCardProps) {
return (
<Box
onClick={onClick}
sx={{
position: 'relative',
bgcolor: 'background.paper',
borderRadius: 4,
p: 2.5,
cursor: 'pointer',
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex',
flexDirection: 'column',
gap: 1.5,
'&:hover': {
borderColor: colorCode,
transform: 'translateY(-4px)',
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode
'& .arrow-icon': {
transform: 'translateX(4px)',
color: colorCode
}
}
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Stack direction="row" spacing={1.5} alignItems="center">
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 3,
bgcolor: `${colorCode}11`, // 7% opacity
color: colorCode
}}
>
{icon}
</Box>
<Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: 700,
lineHeight: 1.2,
color: 'text.primary',
display: 'flex',
alignItems: 'center',
gap: 0.5
}}
>
{title}
{hasAI && <AutoAwesomeIcon sx={{ fontSize: 14, color: '#f5b041' }} />}
</Typography>
{description && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontWeight: 500,
display: 'block',
mt: 0.5
}}
>
{description}
</Typography>
)}
</Box>
</Stack>
<ArrowForwardIosIcon
className="arrow-icon"
sx={{
fontSize: 12,
color: 'grey.300',
mt: 0.5,
transition: 'all 0.3s ease'
}}
/>
</Stack>
{snapshot && (
<Box
sx={{
mt: 'auto',
pt: 1.5,
borderTop: '1px dashed',
borderColor: 'grey.100'
}}
>
{snapshot}
</Box>
)}
</Box>
);
}
-78
View File
@@ -1,78 +0,0 @@
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import { useRouter } from '@/providers/RouterProvider';
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack } = useRouter();
const handleDetach = () => {
// 弹出脱离窗口 (以独立面板形式打开当前 URL)
chrome.windows.create({
url: window.location.href,
type: 'panel',
width: 420,
height: 600
});
};
const isDashboard = currentPage === 'dashboard';
return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{
px: 2,
py: 1.5,
borderBottom: '1px solid',
borderColor: 'grey.100',
bgcolor: 'background.paper',
zIndex: 1100
}}
>
<Box sx={{ width: 40 }}>
{!isDashboard && (
<IconButton
size="small"
onClick={goBack}
sx={{
bgcolor: 'grey.50',
'&:hover': { bgcolor: 'grey.200' }
}}
>
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
</IconButton>
)}
</Box>
<Typography
variant="subtitle2"
sx={{
fontWeight: 800,
letterSpacing: '0.5px',
textTransform: 'uppercase',
fontSize: '0.75rem',
color: 'text.secondary'
}}
>
Testing Tools
</Typography>
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
<Tooltip title="独立窗口模式">
<IconButton size="small" onClick={handleDetach}>
<OpenInNewIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
<Tooltip title="设置">
<IconButton size="small" onClick={onOpenOptions}>
<SettingsIcon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
</Stack>
</Stack>
);
}
-58
View File
@@ -1,58 +0,0 @@
import type { PageType } from '@/types/storage';
import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
export interface RouteConfig {
key: PageType;
label: string;
defaultVisible: boolean;
component: React.ComponentType;
}
export const ROUTES: RouteConfig[] = [
{
key: 'dashboard',
label: 'Dashboard',
defaultVisible: true,
component: DashboardPage,
},
{
key: 'timestamp',
label: '时间戳',
defaultVisible: true,
component: TimestampPage,
},
{
key: 'storageCleaner',
label: '存储清理',
defaultVisible: true,
component: StorageCleanerPage,
},
{
key: 'openUrl',
label: 'Open Url',
defaultVisible: true,
component: OpenUrlPage,
},
{
key: 'openUrlViewer',
label: '查看',
defaultVisible: false,
component: OpenUrlViewerPage,
},
];
export function getRouteByKey(key: PageType): RouteConfig | undefined {
return ROUTES.find(route => route.key === key);
}
export function getDefaultVisibleRoutes(): PageType[] {
return ROUTES.filter(route => route.defaultVisible).map(route => route.key);
}
export function getAllRouteKeys(): PageType[] {
return ROUTES.map(route => route.key);
}
@@ -1,32 +0,0 @@
# 实施计划:修复 Popup 布局与滚动条样式
## 1. 目标
按照设计规范实施固定高度布局与极简滚动条,确保扩展弹窗显示稳定且美观。
## 2. 实施步骤
### 2.1 CSS 核心样式更新 (`entrypoints/popup/App.css`)
1. **视口锁定**: 更新 `html`, `body` 样式,固定 `width: 400px`, `height: 600px`
2. **根容器改造**:
-`.app` 修改为 Flex 容器:`display: flex; flex-direction: column; height: 100%; overflow: hidden;`
- 移除 `margin: 0 auto;``min-height: 100%;`
3. **滚动条变量与全局注入**:
-`:root` 中定义 `--sb-` 开头的滚动条样式变量。
- 使用 `*::-webkit-scrollbar` 系列伪元素定义全局极简滚动条。
### 2.2 React 结构重构 (`entrypoints/popup/App.tsx`)
1. **注入滚动容器**: 在 `nav-container` 之后,将所有的页面渲染(`TimestampPage`, `StorageCleanerPage`)包裹在一个统一的 `Box` 中。
2. **设置容器样式**: 给该 `Box` 设置 `sx={{ flex: 1, overflowY: 'auto', scrollbarGutter: 'stable' }}`
### 2.3 质量保障
1. **Lint 检测**: 运行 `npm run lint` 检查样式变量引用和 JSX 结构。
2. **类型检查**: 运行 `npm run compile` 验证 MUI 组件属性。
## 3. 验证计划
- 手动切换导航栏,观察窗口尺寸是否维持在 600px。
- 在“存储清理”页面展开所有选项,观察右侧是否出现极细滚动条,且不会挤压内容。
@@ -1,97 +0,0 @@
# CLAUDE.md Reorganization Design
**Date**: 2026-03-25
**Status**: Approved & Implemented
**Related Files**: `/CLAUDE.md`
## Overview
Reorganized the existing CLAUDE.md file to improve clarity, flow, and usability for future Claude Code instances working with this browser extension project.
## Problem Statement
The existing CLAUDE.md file contained comprehensive information but had organizational issues:
- Mixed development commands, architecture, and implementation details
- Redundant information in multiple sections
- Lack of clear logical flow from setup to development to reference
- Missing some technical details (path aliases, messaging system explanation)
## Design Goals
1. **Improve logical flow**: Structure content in order of developer needs
2. **Reduce redundancy**: Eliminate duplicate information
3. **Enhance readability**: Use clearer headings and organization
4. **Maintain completeness**: Preserve all essential information
5. **Add missing context**: Include path aliases and other technical specifics
## Solution Design
### Reorganized Structure
1. **Quick Start** - Essential commands and setup (first thing developers need)
2. **Architecture Overview** - Tech stack and high-level structure (context before diving in)
3. **Core Features** - What the extension does (timestamp conversion, storage cleaning)
4. **Development Workflow** - How to work with the codebase (browser compatibility, code quality tools)
5. **Configuration & Implementation** - Reference details (wxt.config.ts, manifest permissions)
6. **CI/CD & Project Context** - Background information (GitHub Actions, project history)
### Key Improvements
1. **Command Table**: Replaced bullet list with markdown table for better readability
2. **Simplified Directory Structure**: Removed excessive detail while maintaining clarity
3. **Logical Grouping**: Related information placed together (e.g., all storage cleaning details)
4. **Added Missing Information**: Path aliases (`@/`), TypeScript configuration highlights
5. **Clearer Section Titles**: More descriptive headings that indicate content purpose
### Content Preservation
All essential information from the original CLAUDE.md was preserved:
- All npm commands and their purposes
- Tech stack details
- Directory structure (simplified but complete)
- Core feature descriptions
- Storage cleaning implementation details
- Manifest permissions
- CI/CD workflow information
- Project history context
## Implementation Details
### File Changes
- **CLAUDE.md**: Complete rewrite with reorganized structure
- **No other files modified**: Only documentation changes
### Structural Changes
1. **Moved commands to front**: Developers need these immediately
2. **Grouped related topics**: All storage-related information together
3. **Separated workflow from reference**: Development process vs. configuration details
4. **Added visual hierarchy**: Clear section headings and subheadings
### Content Additions
1. **Path aliases section**: Explains `@/` import pattern
2. **TypeScript configuration highlights**: Key settings called out
3. **Better cross-references**: Links between related sections
## Validation
The reorganized CLAUDE.md was validated against:
- ✅ All original commands preserved
- ✅ All architectural information maintained
- ✅ All feature descriptions included
- ✅ All configuration details retained
- ✅ Improved readability and flow
- ✅ Added missing technical context
## Success Criteria
1. **Quick access to commands**: Developers can find essential npm scripts immediately
2. **Clear understanding of architecture**: Tech stack and structure explained upfront
3. **Logical information flow**: Follows natural developer workflow
4. **Complete reference**: All necessary information preserved and organized
5. **Improved usability**: Easier for Claude Code instances to understand and work with the project
## Future Considerations
1. **Regular updates**: CLAUDE.md should be updated when project structure changes
2. **User feedback**: Monitor if the reorganization improves developer experience
3. **Additional context**: Consider adding troubleshooting tips or common issues section if needed
@@ -1,48 +0,0 @@
# 设计规范:Popup 弹窗固定高度与极简滚动条
## 1. 目标
解决 Chrome 扩展弹窗高度“只增不减”的布局问题,提供稳定的 600px 固定高度体验,并实现符合极简主义设计规范的可复用滚动条样式。
## 2. 核心架构方案 (方案 A)
采用 **Flex 布局 + 视口锁定** 的策略,将弹窗尺寸固定在 `400px * 600px`
### 2.1 容器层级设计
- **`html`, `body`**: 锁定为 `400px * 600px`,并设置 `overflow: hidden` 防止出现双滚动条。
- **`.app` (根容器)**:
- 设置为 `display: flex; flex-direction: column;`
- 高度撑满父级 (`100%`)。
- 锁定 `overflow: hidden`
- **`nav-container` (导航栏)**:
- 位于顶部,高度固定,不参与 Flex 缩放。
- **内容展示区 (Page Container)**:
- 设置 `flex: 1`,自动填充剩余空间。
- 设置 `overflow-y: auto`,启用局部滚动。
- 设置 `scrollbar-gutter: stable`,预留滚动条空间,防止布局抖动。
## 3. 极简滚动条设计规范
为了确保在所有页面和组件中表现一致,采用全局 Webkit 伪元素定制方案。
### 3.1 变量定义
`:root` 中定义 CSS 变量以增强兼容性和可维护性:
- `--sb-width`: `6px` (极细)
- `--sb-thumb-color`: `rgba(0, 0, 0, 0.1)` (浅灰色半透明)
- `--sb-thumb-hover`: `rgba(0, 0, 0, 0.18)` (悬停时微深)
- `--sb-track-color`: `transparent` (背景完全透明)
### 3.2 样式表现
- **滑块 (Thumb)**: 胶囊状圆角 (`10px`)。
- **背景剪裁 (Background Clip)**: 使用 `content-box` 结合透明边框来实现滑块与边缘的微距感。
- **自动应用**: 使用通配符 `*::-webkit-scrollbar` 确保全局所有溢出容器均自动继承此样式。
## 4. 成功准则
- 切换“时间戳”和“存储清理”页面时,浏览器窗口大小保持 `600px` 不变。
- 当页面内容超过视口时,右侧显示细长、半透明的滚动条。
- 内容加载或 Accordion 展开时,页面水平方向不发生位移。
-11
View File
@@ -2,17 +2,6 @@ import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
export default defineBackground(() => { export default defineBackground(() => {
// 监听扩展图标点击事件,打开侧边栏
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:', err);
}
}
});
// 监听扩展安装或更新事件 // 监听扩展安装或更新事件
browser.runtime.onInstalled.addListener(async ({ reason }) => { browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') { if (reason === 'install') {
-9
View File
@@ -1,9 +0,0 @@
.app {
min-height: 100vh;
background-color: #f5f5f5;
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;
}
-182
View File
@@ -1,182 +0,0 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Paper,
Switch,
Button,
Snackbar,
Alert,
CircularProgress,
Stack,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import { ROUTES } from '@/config/routes';
function App() {
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [toastSeverity, setToastSeverity] = useState<'success' | 'info' | 'warning'>('info');
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
const saved = await storageUtil.get('app/visiblePages', [
'timestamp',
'storageCleaner',
'openUrl',
] as PageType[]);
setVisiblePages(saved ?? ['timestamp', 'storageCleaner', 'openUrl']);
} catch (error) {
console.error('Failed to load config:', error);
setVisiblePages(['timestamp', 'storageCleaner', 'openUrl']);
} finally {
setIsLoaded(true);
}
};
const handlePageToggle = async (page: PageType) => {
const isCurrentlyVisible = visiblePages.includes(page);
let newPages: PageType[];
if (isCurrentlyVisible) {
if (visiblePages.length <= 1) {
showToast('至少需要保留一个可见页面', 'warning');
return;
}
newPages = visiblePages.filter((p) => p !== page);
} else {
newPages = [...visiblePages, page];
}
try {
await storageUtil.set('app/visiblePages', newPages);
setVisiblePages(newPages);
const route = ROUTES.find((r) => r.key === page);
showToast(`${isCurrentlyVisible ? '隐藏' : '显示'} ${route?.label || page}`, 'success');
} catch (error) {
console.error('Failed to save config:', error);
showToast('保存失败', 'warning');
}
};
const handleRestoreDefaults = async () => {
try {
const defaults = ROUTES.filter((route) => route.defaultVisible).map((route) => route.key);
await storageUtil.set('app/visiblePages', defaults);
setVisiblePages(defaults);
showToast('已恢复默认', 'success');
} catch (error) {
console.error('Failed to restore defaults:', error);
showToast('恢复失败', 'warning');
}
};
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
setToast(message);
setToastSeverity(severity);
};
const handleCloseToast = () => setToast(null);
if (!isLoaded) {
return (
<Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
>
<CircularProgress size={24} />
</Box>
);
}
return (
<Box sx={{ p: 4, maxWidth: 600, mx: 'auto', minHeight: '100vh', bgcolor: 'grey.50' }}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 4 }}>
<Button
variant="text"
size="small"
onClick={handleRestoreDefaults}
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
sx={{ color: 'text.secondary', fontWeight: 600 }}
>
</Button>
</Stack>
<Paper
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.200',
overflow: 'hidden',
bgcolor: 'background.paper',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{ROUTES.filter((route) => route.key !== 'dashboard' && route.key !== 'openUrlViewer').map(
(route, index, array) => {
const isChecked = visiblePages.includes(route.key);
const isDisabled = isChecked && visiblePages.length === 1;
return (
<Box
key={route.key}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 2.5,
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
borderColor: 'grey.100',
transition: 'all 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box>
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
{route.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
</Typography>
</Box>
<Switch
size="small"
checked={isChecked}
onChange={() => handlePageToggle(route.key)}
disabled={isDisabled}
/>
</Box>
);
},
)}
</Box>
</Paper>
<Snackbar
open={!!toast}
autoHideDuration={2000}
onClose={handleCloseToast}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={handleCloseToast}
severity={toastSeverity}
variant="filled"
sx={{ borderRadius: 2, fontWeight: 600 }}
>
{toast}
</Alert>
</Snackbar>
</Box>
);
}
export default App;
+3 -4
View File
@@ -1,12 +1,11 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>扩展设置 - Testing Tools</title> <title>Document</title>
</head> </head>
<body> <body>
<div id="root"></div> Hello Options Page
<script type="module" src="./main.tsx"></script>
</body> </body>
</html> </html>
-11
View File
@@ -1,11 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './App.css';
import '@mui/material/styles';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+47 -59
View File
@@ -1,4 +1,18 @@
/* 极简滚动条变量 */ /* 隐藏页面滚动条 */
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
scrollbar-gutter: stable;
}
/* 隐藏body滚动条但允许滚动 */
body {
overflow: auto;
}
:root { :root {
/* 统一圆角变量 */ /* 统一圆角变量 */
--radius: 8px; --radius: 8px;
@@ -11,81 +25,55 @@
/* 统一按钮文字颜色变量 */ /* 统一按钮文字颜色变量 */
--btn-text: #333333; --btn-text: #333333;
--border: 1px solid #e0e0e0; --border: 1px solid #e0e0e0;
/* 滚动条变量 */
--sb-width: 6px;
--sb-thumb-color: rgba(0, 0, 0, 0.1);
--sb-thumb-hover: rgba(0, 0, 0, 0.2);
--sb-track-color: transparent;
}
/* 强制固定视口尺寸 */
html,
body,
#root {
margin: 0;
padding: 0;
width: 400px;
height: 600px;
overflow: hidden; /* 禁用外层滚动 */
}
/* 全局极简滚动条定制 */
*::-webkit-scrollbar {
width: var(--sb-width);
}
*::-webkit-scrollbar-track {
background: var(--sb-track-color);
}
*::-webkit-scrollbar-thumb {
background: var(--sb-thumb-color);
border-radius: 10px;
background-clip: content-box;
border: 1px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
} }
.app { .app {
display: flex; width: 400px;
flex-direction: column; min-height: 100%;
width: 100%; margin: 0 auto;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
font-family: font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow: hidden; /* 内部由 flex 子项控制滚动 */ overflow-x: hidden;
}
/* Animations */
@keyframes slideInRight {
from {
transform: translateX(30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-5px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0);
} }
} }
.page-transition-enter { /* 导航容器 */
animation: slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards; .nav-container {
display: flex;
justify-content: center;
margin-top: 16px;
margin-bottom: 16px;
gap: 8px;
} }
.page-transition-dashboard { /* 导航按钮 */
animation: fadeIn 0.3s ease-out forwards; .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;
} }
+78 -11
View File
@@ -1,20 +1,87 @@
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'; import './App.css';
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer'; const PAGE_CONFIG = {
timestamp: { label: '时间戳', defaultVisible: true },
storageCleaner: { label: '存储清理', defaultVisible: true },
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
function App() { function App() {
const handleOpenOptions = () => { const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
chrome.runtime.openOptionsPage(); 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 ( return (
<RouterProvider defaultRoute="dashboard" syncRoute={false}> <div className="app">
<div className="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> <Box className="nav-container">
<TopBar onOpenOptions={handleOpenOptions} /> {(Object.keys(PAGE_CONFIG) as PageType[])
<RouterContainer /> .filter((key) => visiblePages.includes(key))
</div> .map((key) => <NavButton key={key} pageKey={key} />)}
</RouterProvider> </Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
); );
} }
-78
View File
@@ -1,78 +0,0 @@
import { Box, Typography, Container } from '@mui/material';
import { useRouter } from '@/providers/RouterProvider';
import ToolCard from '@/components/ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import StorageIcon from '@mui/icons-material/Storage';
import LanguageIcon from '@mui/icons-material/Language';
import type { PageType } from '@/types/storage';
import { useEffect, useState } from 'react';
import dayjs from '@/utils/dayjs';
export default function DashboardPage() {
const { navigateTo, visiblePages } = useRouter();
const [now, setNow] = useState(dayjs());
useEffect(() => {
const timer = setInterval(() => setNow(dayjs()), 1000);
return () => clearInterval(timer);
}, []);
const isVisible = (key: string) => visiblePages.includes(key as PageType);
return (
<Box sx={{ bgcolor: 'grey.50', minHeight: '100%', pb: 4 }}>
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{isVisible('timestamp') && (
<ToolCard
title="时间戳"
description="Unix 毫秒数转换与格式化"
colorCode="#2196f3"
icon={<AccessTimeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('timestamp')}
snapshot={
<Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
>
<Typography
sx={{
fontFamily: 'monospace',
fontWeight: 600,
color: '#2196f3',
fontSize: '0.85rem',
}}
>
{now.valueOf()}
</Typography>
<Typography sx={{ fontSize: '0.7rem', color: 'text.secondary' }}>
{now.format('HH:mm:ss')}
</Typography>
</Box>
}
/>
)}
{isVisible('storageCleaner') && (
<ToolCard
title="存储管理"
description="清理缓存、Cookies 及本地存储"
colorCode="#ff9800"
icon={<StorageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('storageCleaner')}
/>
)}
{isVisible('openUrl') && (
<ToolCard
title="URL 实验室"
description="多环境跳转与安全性预检"
colorCode="#9c27b0"
icon={<LanguageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('openUrl')}
/>
)}
</Box>
</Container>
</Box>
);
}
-403
View File
@@ -1,403 +0,0 @@
import { useState, useEffect, useCallback, Fragment } from 'react';
import {
Box,
TextField,
Alert,
List,
ListItem,
IconButton,
Typography,
Divider,
Container,
Stack,
alpha,
Theme,
Tooltip,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import VisibilityIcon from '@mui/icons-material/Visibility';
import AddIcon from '@mui/icons-material/Add';
import LanguageIcon from '@mui/icons-material/Language';
import LinkIcon from '@mui/icons-material/Link';
import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { storageUtil } from '@/utils/chromeStorage';
import { useRouter } from '@/providers/RouterProvider';
import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
const THEME_COLOR = '#9c27b0';
const INPUT_STYLE = {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 3.5,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
'&.Mui-focused': {
bgcolor: '#fff',
borderColor: THEME_COLOR,
boxShadow: (_theme: Theme) => `0 0 0 4px ${alpha(THEME_COLOR, 0.1)}`,
},
},
'& .MuiInputBase-input': {
py: 1.2,
px: 2,
fontSize: '0.85rem',
fontWeight: 600,
},
'& .MuiInputLabel-root': {
fontSize: '0.85rem',
fontWeight: 700,
color: 'text.secondary',
mb: 0.5,
'&.Mui-focused': { color: THEME_COLOR },
},
};
const DEFAULT_PREFERENCES: OpenUrlPreferences = {
entries: [],
};
export default function OpenUrlPage() {
const [entries, setEntries] = useState<OpenUrlEntry[]>(DEFAULT_PREFERENCES.entries);
const [newName, setNewName] = useState<string>('');
const [newUrl, setNewUrl] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbar();
const { syncNavigation } = useRouter();
const showMixedContentWarning =
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
const isValidUrl = (url: string) => {
if (!url.trim()) return false;
try {
new URL(url);
return true;
} catch {
return false;
}
};
useEffect(() => {
const loadPreferences = async () => {
try {
const saved = await storageUtil.get('openUrl/preferences', DEFAULT_PREFERENCES);
if (saved && saved.entries) {
setEntries(saved.entries);
}
} catch (error) {
console.error('Failed to load Open Url preferences:', error);
} finally {
setIsLoaded(true);
}
};
loadPreferences();
}, []);
const savePreferences = useCallback(() => {
const preferences: OpenUrlPreferences = { entries };
storageUtil.set('openUrl/preferences', preferences).catch((error) => {
console.error('Failed to save Open Url preferences:', error);
});
}, [entries]);
useEffect(() => {
if (!isLoaded) return;
const timer = setTimeout(() => {
savePreferences();
}, 500);
return () => clearTimeout(timer);
}, [entries, isLoaded, savePreferences]);
const handleAddEntry = () => {
if (!newName.trim()) {
showMessage('请输入名称', { severity: 'error' });
return;
}
if (!isValidUrl(newUrl)) {
showMessage('请输入有效的 URL', { severity: 'error' });
return;
}
setEntries([...entries, { name: newName.trim(), url: newUrl.trim() }]);
setNewName('');
setNewUrl('');
showMessage('添加成功', { severity: 'success' });
};
const handleDeleteEntry = (index: number) => {
const newEntries = [...entries];
newEntries.splice(index, 1);
setEntries(newEntries);
showMessage('删除成功', { severity: 'success' });
};
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
try {
await storageUtil.set('openUrl/currentUrl', entry.url);
syncNavigation('openUrlViewer');
const [currentTab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
const tabId = currentTab.id;
if (!tabId) {
showMessage('无法获取当前标签页', { severity: 'error' });
return;
}
await chrome.sidePanel.setOptions({
tabId,
path: 'sidepanel.html',
enabled: true,
});
await chrome.sidePanel.open({ windowId: currentTab.windowId });
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
if (window.location.pathname.includes('popup.html')) {
window.close();
}
} catch (error) {
console.error('Failed to open side panel:', error);
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
}
};
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
chrome.tabs.create({ url: entry.url });
window.close();
};
return (
<Box sx={{ pb: 3 }}>
<Container sx={{ py: 2 }}>
{/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(THEME_COLOR, 0.1),
color: THEME_COLOR,
display: 'flex',
}}
>
<LanguageIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
URL
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Stack>
{/* Form Section */}
<Box
sx={{
bgcolor: 'background.paper',
p: 2,
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
mb: 3,
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
}}
>
<Stack spacing={2}>
<TextField
label="环境名称"
placeholder="例如: 本地文档"
value={newName}
onChange={(e) => setNewName(e.target.value)}
fullWidth
variant="outlined"
sx={INPUT_STYLE}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="目标 URL"
placeholder="例如: http://localhost:8000/docs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
fullWidth
variant="outlined"
sx={INPUT_STYLE}
InputLabelProps={{ shrink: true }}
/>
{showMixedContentWarning && (
<Alert
severity="warning"
sx={{
borderRadius: 3,
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
}}
>
HTTPS HTTP
</Alert>
)}
<Button
variant="contained"
onClick={handleAddEntry}
disabled={!newName.trim() || !isValidUrl(newUrl)}
fullWidth
startIcon={<AddIcon />}
sx={{
py: 1.2,
borderRadius: 4,
bgcolor: THEME_COLOR,
fontWeight: 800,
boxShadow: 'none',
'&:hover': {
bgcolor: alpha(THEME_COLOR, 0.85),
boxShadow: `0 8px 24px ${alpha(THEME_COLOR, 0.2)}`,
},
}}
>
</Button>
</Stack>
</Box>
{/* List Section */}
<Box>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontWeight: 800, px: 1, mb: 1, display: 'block' }}
>
({entries.length})
</Typography>
{entries.length === 0 ? (
<Box
sx={{
textAlign: 'center',
py: 4,
bgcolor: 'grey.50',
borderRadius: 4,
border: '1px dashed',
borderColor: 'grey.200',
}}
>
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
<Typography
variant="caption"
color="text.disabled"
sx={{ display: 'block', fontWeight: 600 }}
>
</Typography>
</Box>
) : (
<List
disablePadding
sx={{
bgcolor: 'background.paper',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
overflow: 'hidden',
}}
>
{entries.map((entry, index) => (
<Fragment key={index}>
<ListItem
sx={{
px: 2,
py: 1.5,
display: 'flex',
alignItems: 'center',
gap: 2,
transition: 'background-color 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{ fontWeight: 800, color: 'text.primary' }}
noWrap
>
{entry.name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
sx={{
fontSize: '0.65rem',
fontWeight: 500,
display: 'block',
mt: 0.2,
fontFamily: 'monospace',
}}
>
{entry.url}
</Typography>
</Box>
<Stack direction="row" spacing={0.5}>
<Tooltip title="在侧边栏预览">
<IconButton
size="small"
onClick={() => handleOpenInSidebar(entry)}
sx={{
color: THEME_COLOR,
bgcolor: alpha(THEME_COLOR, 0.05),
'&:hover': { bgcolor: THEME_COLOR, color: '#fff' },
}}
>
<VisibilityIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="新标签页打开">
<IconButton
size="small"
onClick={() => handleOpenInNewTab(entry)}
sx={{
color: 'grey.500',
bgcolor: 'grey.100',
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
}}
>
<OpenInNewIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Tooltip title="删除">
<IconButton
size="small"
onClick={() => handleDeleteEntry(index)}
sx={{
color: 'error.main',
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Stack>
</ListItem>
{index < entries.length - 1 && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
</Fragment>
))}
</List>
)}
</Box>
</Container>
<GlobalSnackbar {...snackbarProps} />
</Box>
);
}
@@ -1,91 +0,0 @@
import { useState, useEffect } from 'react';
import { Box, Typography } from '@mui/material';
import { storageUtil } from '@/utils/chromeStorage';
// 只允许 HTTP/HTTPS 协议,阻止危险协议
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
export default function OpenUrlViewerPage() {
const [currentUrl, setCurrentUrl] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
// 验证 URL 是否安全
const validateUrl = (url: string): string | null => {
try {
const urlObj = new URL(url);
if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) {
return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`;
}
return null;
} catch {
return '无效的 URL 格式';
}
};
// 从存储加载当前选中的 URL
useEffect(() => {
const loadCurrentUrl = async () => {
try {
const saved = await storageUtil.get('openUrl/currentUrl', '');
if (saved) {
const validationError = validateUrl(saved);
if (validationError) {
setError(validationError);
} else {
setCurrentUrl(saved);
setError(null);
}
}
} catch (error) {
console.error('Failed to load current URL:', error);
setError('加载 URL 失败');
} finally {
setIsLoaded(true);
}
};
loadCurrentUrl();
}, []);
if (!isLoaded) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Typography color="text.secondary">Loading...</Typography>
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Typography color="error">{error}</Typography>
</Box>
);
}
if (!currentUrl) {
return (
<Box sx={{ p: 2, flex: 1 }}>
<Typography color="text.secondary">
URL OpenUrl URL
</Typography>
</Box>
);
}
return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<iframe
src={currentUrl}
title="OpenUrl Viewer"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-navigation"
style={{
flex: 1,
width: '100%',
border: 'none',
display: 'block',
}}
/>
</Box>
);
}
+222 -346
View File
@@ -1,35 +1,23 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Paper,
Typography, Typography,
Box, Box,
Checkbox, Checkbox,
Button,
FormControlLabel,
Alert, Alert,
Divider, Snackbar,
Container,
Stack,
Switch,
Grid,
} from '@mui/material'; } from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import StorageIcon from '@mui/icons-material/Storage';
import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import type { import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
StorageCleanerOptions,
CleaningResult,
StorageCleanerPreferences,
} from '@/types/storage';
import { import {
getCurrentTab, getCurrentTab,
isRestrictedUrl, isRestrictedUrl,
clearStorage, clearStorage,
formatCleaningResult, formatCleaningResult,
getCookieSize,
getLocalStorageSize,
getSessionStorageSize,
formatSize,
} from '@/utils/storageCleaner'; } from '@/utils/storageCleaner';
const DEFAULT_OPTIONS: StorageCleanerOptions = { const DEFAULT_OPTIONS: StorageCleanerOptions = {
@@ -50,403 +38,291 @@ export default function StorageCleanerPage() {
const [domain, setDomain] = useState<string>(''); const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [sizes, setSizes] = useState<Record<string, number>>({});
const [autoRefresh, setAutoRefresh] = useState<boolean>(true); const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null); const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false); const [showConfirm, setShowConfirm] = useState<boolean>(false);
const { snackbarProps, showMessage } = useSnackbar(); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null); open: false,
message: '',
});
// Load tab info and user preferences
useEffect(() => { useEffect(() => {
return () => { const loadInfo = async () => {
if (reloadTimeoutRef.current) clearTimeout(reloadTimeoutRef.current); 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);
}; };
}, []);
const loadInfo = async () => {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
setError('无法获取当前标签页');
return;
}
if (isRestrictedUrl(tab.url)) {
setError('存储清理功能不支持此页面');
return;
}
const url = tab.url;
const tabId = tab.id!;
setDomain(new URL(url).hostname);
const [savedPrefs, cSize, lsSize, ssSize] = await Promise.all([
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
getCookieSize(url),
getLocalStorageSize(tabId),
getSessionStorageSize(tabId),
]);
setAutoRefresh(savedPrefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
setOptions(savedPrefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
setSizes({
cookies: cSize,
localStorage: lsSize,
sessionStorage: ssSize,
});
};
useEffect(() => {
loadInfo(); loadInfo();
}, []); }, []);
const handleAutoRefreshChange = useCallback( const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
async (checked: boolean) => { setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
setAutoRefresh(checked); }, []);
await storageUtil.set('storageCleaner/preferences', {
autoRefresh: checked,
selectedTypes: options,
});
},
[options],
);
const handleOptionChange = useCallback(
async (key: keyof StorageCleanerOptions) => {
setOptions((prev) => {
const newOptions = { ...prev, [key]: !prev[key] };
storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: newOptions,
});
return newOptions;
});
},
[autoRefresh],
);
const allSelected = Object.values(options).every(Boolean);
const someSelected = Object.values(options).some(Boolean) && !allSelected;
const handleSelectAll = useCallback(
async (checked: boolean) => {
const newOptions = {
localStorage: checked,
sessionStorage: checked,
indexedDB: checked,
cookies: checked,
cacheStorage: checked,
serviceWorkers: checked,
};
setOptions(newOptions);
await storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: newOptions,
});
},
[autoRefresh],
);
const handleClean = useCallback(async () => { const handleClean = useCallback(async () => {
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) { if (!tab || !tab.id || !tab.url) {
showMessage('无法获取当前标签页'); setSnackbar({ open: true, message: '无法获取当前标签页' });
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const cleaningResult = await clearStorage(tab.id, tab.url, options); const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult); setResult(cleaningResult);
// Save user preferences
await storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: options,
});
// Auto refresh if enabled
if (autoRefresh && cleaningResult.success && tab.id !== undefined) { if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
showMessage('清理成功,即将刷新页面'); setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
reloadTimeoutRef.current = setTimeout(() => { setTimeout(() => {
chrome.tabs.reload(tab.id!); chrome.tabs.reload(tab.id!);
}, 1500); }, 1500);
} else {
loadInfo();
} }
} catch (err) { } catch (err) {
showMessage(`清理失败: ${String(err)}`, { severity: 'error' }); setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
} finally { } finally {
setLoading(false); setLoading(false);
setShowConfirm(false); setShowConfirm(false);
} }
}, [options, autoRefresh, showMessage]); }, [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) { if (error) {
return ( return (
<Container sx={{ py: 4 }}> <Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Alert severity="error" icon={<WarningIcon />} sx={{ borderRadius: 3 }}> <Alert severity="error" icon={<WarningIcon />}>
{error} {error}
</Alert> </Alert>
</Container> </Paper>
); );
} }
const totalSize = Object.values(sizes).reduce((acc, curr) => acc + curr, 0);
const OptionItem = ({
label,
checked,
size,
onChange,
}: {
label: string;
checked: boolean;
size?: number;
onChange: () => void;
}) => (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 0.6,
px: 1.2,
borderRadius: 2.5,
transition: 'all 0.2s',
'&:hover': { bgcolor: 'grey.50' },
}}
>
<Stack direction="row" spacing={0.8} alignItems="baseline">
<Typography
variant="caption"
fontWeight={700}
color="text.primary"
sx={{ fontSize: '0.75rem' }}
>
{label}
</Typography>
{size !== undefined && size > 0 && (
<Typography
variant="caption"
sx={{ color: 'text.disabled', fontSize: '0.65rem', fontWeight: 500 }}
>
{formatSize(size)}
</Typography>
)}
</Stack>
<Checkbox
size="small"
checked={checked}
onChange={onChange}
color="warning"
sx={{ p: 0.5 }}
/>
</Box>
);
return ( return (
<Box sx={{ pb: 2 }}> <Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Container sx={{ py: 2 }}> {/* Header */}
{/* Domain Header */} <Box sx={{ textAlign: 'center', mb: 2 }}>
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2 }}> <Typography variant="h5" component="h1" sx={{ mb: 1 }}>
<Box
sx={{ </Typography>
p: 1, <Typography variant="body2" color="text.secondary">
borderRadius: 2.5, : {domain || '加载中...'}
bgcolor: '#fff4e5', </Typography>
color: '#ff9800', </Box>
display: 'flex',
}}
>
<StorageIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography>
{totalSize > 0 && (
<Typography
variant="caption"
sx={{
bgcolor: '#fff4e5',
color: '#ff9800',
px: 1,
py: 0.2,
borderRadius: 1.5,
fontWeight: 800,
fontSize: '0.65rem',
}}
>
{formatSize(totalSize)}
</Typography>
)}
</Stack>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontWeight: 600,
display: 'block',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{domain || '加载中...'}
</Typography>
</Box>
</Stack>
{/* Storage Options Grid */} {/* Storage Type Options */}
<Box <Box sx={{ mb: 2 }}>
sx={{ <Typography variant="subtitle1" sx={{ mb: 1 }}>
mb: 2,
border: '1px solid', </Typography>
borderColor: 'grey.100', <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
borderRadius: 4, <FormControlLabel
p: 0.8, control={
bgcolor: 'background.paper', <Checkbox
}}
>
<Grid container spacing={0}>
<Grid size={6}>
<OptionItem
label="LocalStorage"
checked={options.localStorage} checked={options.localStorage}
size={sizes.localStorage}
onChange={() => handleOptionChange('localStorage')} onChange={() => handleOptionChange('localStorage')}
/> />
</Grid> }
<Grid size={6}> label="localStorage"
<OptionItem />
label="Session" <FormControlLabel
control={
<Checkbox
checked={options.sessionStorage} checked={options.sessionStorage}
size={sizes.sessionStorage}
onChange={() => handleOptionChange('sessionStorage')} onChange={() => handleOptionChange('sessionStorage')}
/> />
</Grid> }
<Grid size={6}> label="sessionStorage"
<OptionItem />
label="IndexedDB" <FormControlLabel
control={
<Checkbox
checked={options.indexedDB} checked={options.indexedDB}
onChange={() => handleOptionChange('indexedDB')} onChange={() => handleOptionChange('indexedDB')}
/> />
</Grid> }
<Grid size={6}> label="IndexedDB"
<OptionItem />
label="Cookies" <FormControlLabel
control={
<Checkbox
checked={options.cookies} checked={options.cookies}
size={sizes.cookies}
onChange={() => handleOptionChange('cookies')} onChange={() => handleOptionChange('cookies')}
/> />
</Grid> }
<Grid size={6}> label="Cookies"
<OptionItem />
label="Cache" <FormControlLabel
control={
<Checkbox
checked={options.cacheStorage} checked={options.cacheStorage}
onChange={() => handleOptionChange('cacheStorage')} onChange={() => handleOptionChange('cacheStorage')}
/> />
</Grid> }
<Grid size={6}> label="Cache Storage"
<OptionItem />
label="Workers" <FormControlLabel
control={
<Checkbox
checked={options.serviceWorkers} checked={options.serviceWorkers}
onChange={() => handleOptionChange('serviceWorkers')} onChange={() => handleOptionChange('serviceWorkers')}
/> />
</Grid> }
</Grid> label="Service Workers"
<Divider sx={{ my: 0.8, borderColor: 'grey.50' }} />
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
px: 1.2,
py: 0.4,
}}
>
<Typography
variant="caption"
fontWeight={800}
sx={{ color: 'text.secondary', fontSize: '0.65rem' }}
>
</Typography>
<Checkbox
size="small"
checked={allSelected}
indeterminate={someSelected}
onChange={(e) => handleSelectAll(e.target.checked)}
color="warning"
sx={{ p: 0.5 }}
/>
</Box>
</Box>
{/* Auto Refresh Toggle */}
<Box
sx={{
mb: 2,
p: 1.2,
borderRadius: 4,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'grey.100',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption" fontWeight={700}>
</Typography>
<Switch
size="small"
checked={autoRefresh}
onChange={(e) => handleAutoRefreshChange(e.target.checked)}
color="warning"
/> />
</Box> </Box>
</Box>
{/* Primary Action */} {/* 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 <Button
variant="contained" variant="contained"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
sx={{
py: 1.2,
borderRadius: 4,
bgcolor: '#ff9800',
fontWeight: 800,
fontSize: '0.85rem',
boxShadow: 'none',
'&:hover': { bgcolor: '#f57c00', boxShadow: '0 8px 16px rgba(255, 152, 0, 0.2)' },
}}
disabled={loading} disabled={loading}
fullWidth fullWidth
> >
{loading ? '正在清理...' : '立即清理'} {loading ? '清理...' : '清理'}
</Button> </Button>
</Box>
{/* Result & Refresh Secondary Action */} {/* Result Display */}
{result && ( {result && (
<Box sx={{ mt: 2 }}> <Box sx={{ mb: 2 }}>
<Alert <Alert
severity={result.success ? 'success' : 'error'} severity={result.success ? 'success' : 'error'}
sx={{ sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
borderRadius: 2.5, >
py: 0, {result.success ? formatCleaningResult(result) : result.error || '清理失败'}
'& .MuiAlert-message': { fontSize: '0.75rem', fontWeight: 600 }, </Alert>
}} {!autoRefresh && result.success && (
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={handleRefresh}
fullWidth
> >
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
</Alert> </Button>
</Box> )}
)} </Box>
</Container> )}
<StorageCleanerConfirm {/* Confirmation Dialog */}
open={showConfirm} {showConfirm && (
onClose={() => setShowConfirm(false)} <Paper
onConfirm={handleClean} sx={{
options={options} position: 'absolute',
/> top: 0,
<GlobalSnackbar {...snackbarProps} /> left: 0,
</Box> 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>
); );
} }
+191 -270
View File
@@ -1,25 +1,26 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import dayjs from '@/utils/dayjs'; import dayjs from '@/utils/dayjs';
import { import {
Button,
TextField, TextField,
Select, Select,
MenuItem, MenuItem,
Paper,
Stack, Stack,
Typography, Typography,
Box, Box,
IconButton, IconButton,
Snackbar,
Alert,
InputAdornment,
alpha, alpha,
Tooltip, Tooltip,
Theme, Theme,
Container,
Fade,
Divider,
} from '@mui/material'; } from '@mui/material';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from '@mui/icons-material/Check';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import Button from '@/components/Button';
// ================= 常量配置 ================= // ================= 常量配置 =================
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss'; const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
@@ -30,45 +31,33 @@ type ZoneType = (typeof ZONES)[number];
const INPUT_STYLE = { const INPUT_STYLE = {
'& .MuiOutlinedInput-root': { '& .MuiOutlinedInput-root': {
bgcolor: 'background.paper', bgcolor: 'grey.50',
borderRadius: 3.5, borderRadius: 3,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' }, '& fieldset': { border: 'none' },
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' }, '&:hover': { bgcolor: 'grey.100' },
'&.Mui-focused': { '&.Mui-focused': {
bgcolor: '#fff', bgcolor: '#fff',
borderColor: 'primary.main', boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.primary.main, 0.1)}`,
}, },
'&.Mui-error': { '&.Mui-error': {
borderColor: 'error.main', boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.error.main, 0.1)}`,
}, },
}, },
'& .MuiInputBase-input': { '& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
py: 1.4,
px: 2,
fontSize: '0.9rem',
fontFamily: 'monospace',
fontWeight: 600
},
}; };
// ================= 子组件:实时时钟 (优化交互) ================= // ================= 子组件:实时时钟 =================
interface LiveClockProps { interface LiveClockProps {
unit: UnitType; unit: UnitType;
onCopy: (val: string) => void; onCopy: (val: string) => void;
onUseNow: (val: number) => void; onUseNow: (val: number) => void;
onUnitChange: (u: UnitType) => void;
} }
const LiveClock = React.memo(({ const LiveClock = React.memo(({
unit, unit,
onCopy, onCopy,
onUseNow, onUseNow
onUnitChange
}: LiveClockProps) => { }: LiveClockProps) => {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -82,78 +71,36 @@ const LiveClock = React.memo(({
[now, unit]); [now, unit]);
return ( return (
<Box sx={{ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
display: 'flex', <Stack direction="row" spacing={1} alignItems="baseline">
alignItems: 'center', <Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
justifyContent: 'space-between',
p: 1.8,
mb: 2.5,
bgcolor: alpha('#2196f3', 0.04),
borderRadius: 4,
border: '1px solid',
borderColor: alpha('#2196f3', 0.1)
}}>
<Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: 'primary.main', fontWeight: 800, fontSize: '0.6rem', textTransform: 'uppercase', letterSpacing: 1 }}>
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.primary', fontFamily: 'monospace', fontSize: '1.2rem', letterSpacing: '-0.5px', lineHeight: 1.2 }}>
{displayVal} {displayVal}
</Typography> </Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
{unit}
</Typography>
</Stack> </Stack>
<Stack direction="row" spacing={0.5}>
<Stack direction="row" spacing={1} alignItems="center"> <Tooltip title="填充到下方">
{/* 胶囊式单位切换器 */}
<Box sx={{
display: 'flex',
p: 0.4,
bgcolor: alpha('#2196f3', 0.08),
borderRadius: 2.5,
border: '1px solid',
borderColor: alpha('#2196f3', 0.1)
}}>
{(['ms', 's'] as const).map((u) => (
<Box
key={u}
onClick={() => onUnitChange(u)}
sx={{
px: 1.2,
py: 0.35,
borderRadius: 2,
cursor: 'pointer',
fontSize: '0.65rem',
fontWeight: 900,
transition: 'all 0.2s',
bgcolor: unit === u ? '#fff' : 'transparent',
color: unit === u ? 'primary.main' : alpha('#2196f3', 0.4),
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
}}
>
{u.toUpperCase()}
</Box>
))}
</Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, my: 1, borderColor: alpha('#2196f3', 0.1) }} />
<Stack direction="row" spacing={0.5}>
<Tooltip title="填充到下方">
<IconButton
size="small"
onClick={() => onUseNow(now)}
sx={{ color: 'primary.main', bgcolor: '#fff', boxShadow: '0 2px 4px rgba(0,0,0,0.05)', '&:hover': { bgcolor: 'primary.main', color: '#fff' } }}
>
<AccessTimeIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton <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" size="small"
onClick={() => onCopy(displayVal)} onClick={() => onCopy(displayVal)}
sx={{ color: 'grey.400', '&:hover': { color: 'primary.main' } }} sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
> >
<ContentCopyIcon fontSize="small" /> <ContentCopyIcon fontSize="small" />
</IconButton> </IconButton>
</Stack> </Tooltip>
</Stack> </Stack>
</Box> </Box>
); );
@@ -199,79 +146,73 @@ const ResultView = React.memo(({
if (!result) return null; if (!result) return null;
return ( return (
<Fade in={!!result}> <Box sx={{
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}> mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1.2, display: 'block', fontWeight: 800, fontSize: '0.7rem' }}> animation: 'fadeIn 0.3s ease-out',
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
</Typography> }}>
<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),
},
}}
/>
<Box sx={{ {/* 辅助信息预览 */}
bgcolor: alpha('#2196f3', 0.05), <Stack spacing={1} sx={{ px: 1 }}>
p: 2, {[
borderRadius: 4, { label: '相对时间', value: extraInfo?.relative },
position: 'relative', { label: 'ISO 8601', value: extraInfo?.iso },
mb: 2.5, { label: 'UTC 时间', value: extraInfo?.utc },
border: '1px solid', ].map((item) => (
borderColor: alpha('#2196f3', 0.1) <Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
}}> <Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
<Typography <Typography
variant="body1" variant="caption"
sx={{ onClick={() => { if (item.value) onCopy(item.value); }}
fontFamily: 'monospace', sx={{
fontWeight: 700, fontFamily: 'monospace',
color: 'primary.main', color: 'text.primary',
wordBreak: 'break-all', cursor: 'pointer',
pr: 4, '&:hover': { color: 'primary.main', textDecoration: 'underline' }
fontSize: '1rem' }}
}} >
> {item.value}
{result} </Typography>
</Typography> </Box>
<IconButton ))}
size="small" </Stack>
onClick={handleCopy} </Box>
sx={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
color: copied ? 'success.main' : 'primary.main',
bgcolor: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
'&:hover': { bgcolor: copied ? 'success.main' : 'primary.main', color: '#fff' }
}}
>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Box>
<Stack spacing={1.2}>
{[
{ 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', px: 1 }}>
<Typography variant="caption" sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}>{item.label}</Typography>
<Typography
variant="caption"
onClick={() => { if (item.value) onCopy(item.value); }}
sx={{
fontFamily: 'monospace',
color: 'text.secondary',
fontWeight: 600,
fontSize: '0.65rem',
cursor: 'pointer',
'&:hover': { color: 'primary.main' }
}}
>
{item.value}
</Typography>
</Box>
))}
</Stack>
</Box>
</Fade>
); );
}); });
@@ -286,16 +227,16 @@ export default function TimestampPage() {
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai'); const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
const [result, setResult] = useState(''); const [result, setResult] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
const copy = useCallback(async (text: string) => { const copy = useCallback(async (text: string) => {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
showMessage('已复制', { severity: 'success' }); setSnack({ open: true, msg: '已复制' });
} catch { } catch {
showMessage('复制失败', { severity: 'error' }); setSnack({ open: true, msg: '复制失败' });
} }
}, [showMessage]); }, []);
const convert = useCallback(() => { const convert = useCallback(() => {
if (mode === 'ts2dt') { if (mode === 'ts2dt') {
@@ -318,9 +259,14 @@ export default function TimestampPage() {
} }
}, [mode, tsInput, dtInput, unit, zone]); }, [mode, tsInput, dtInput, unit, zone]);
// 智能实时转换 (Debounce Effect)
useEffect(() => { useEffect(() => {
const timer = setTimeout(convert, 400); const timer = setTimeout(() => {
return () => clearTimeout(timer); convert();
}, 400);
return () => {
clearTimeout(timer);
};
}, [convert]); }, [convert]);
const handleUseNow = useCallback((now: number) => { const handleUseNow = useCallback((now: number) => {
@@ -332,71 +278,65 @@ export default function TimestampPage() {
}, [mode, unit, zone]); }, [mode, unit, zone]);
return ( return (
<Box sx={{ pb: 3 }}> <Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
<Container sx={{ py: 2 }}> <Paper
{/* Header with Icon */} elevation={0}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}> sx={{
<Box sx={{ p: 1, borderRadius: 2.5, bgcolor: alpha('#2196f3', 0.1), color: 'primary.main', display: 'flex' }}> p: 2.5,
<AccessTimeIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight={900} sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
Unix
</Typography>
</Box>
</Stack>
{/* Live Clock Card */}
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} onUnitChange={setUnit} />
{/* Mode Switcher */}
<Box sx={{
position: 'relative',
display: 'flex',
p: 0.6,
bgcolor: 'grey.100',
borderRadius: 4, borderRadius: 4,
mb: 2.5,
border: '1px solid', border: '1px solid',
borderColor: 'grey.200' borderColor: 'grey.100',
}}> transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
<Box sx={{ '&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
position: 'absolute', }}
height: 'calc(100% - 10px)', >
width: 'calc(50% - 5px)', {/* 1. 实时时钟 */}
bgcolor: '#fff', <Box sx={{ position: 'relative' }}>
borderRadius: 3.5, <LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
boxShadow: '0 4px 12px rgba(0,0,0,0.08)', <Tooltip title="切换单位">
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)', <IconButton
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)', aria-label="switch unit"
top: 5, left: 5, 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) => ( {(['ts2dt', 'dt2ts'] as const).map((m) => (
<Box <Button
key={m} key={m} fullWidth disableRipple
onClick={() => { setMode(m); setError(''); setResult(''); }} onClick={() => { setMode(m); setError(''); setResult(''); }}
sx={{ sx={{
flex: 1, position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
py: 1, fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
textAlign: 'center', color: mode === m ? 'text.primary' : 'text.disabled',
position: 'relative', '&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
zIndex: 1,
cursor: 'pointer',
fontWeight: 800,
fontSize: '0.75rem',
color: mode === m ? 'primary.main' : 'text.secondary',
transition: 'color 0.3s'
}} }}
> >
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'} {m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
</Box> </Button>
))} ))}
</Box> </Box>
{/* Input Area */} {/* 3. 输入与设置 */}
<Stack spacing={2} sx={{ mb: 3 }}> <Stack spacing={2} sx={{ mb: 3 }}>
<TextField <TextField
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT} placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
@@ -416,76 +356,57 @@ export default function TimestampPage() {
sx={INPUT_STYLE} sx={INPUT_STYLE}
/> />
<Stack direction="row" spacing={1.5}> <Stack direction="row" spacing={2}>
{/* 优化后的单位选择按钮组 */} <Select
<Box sx={{ fullWidth value={unit}
flex: 1, onChange={(e) => { setUnit(e.target.value as UnitType); }}
display: 'flex', sx={{ ...INPUT_STYLE, flex: 1 }}
bgcolor: 'grey.50', MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
p: 0.5, >
borderRadius: 3.5, <MenuItem value="ms"> (ms)</MenuItem>
border: '1px solid', <MenuItem value="s"> (s)</MenuItem>
borderColor: 'grey.100' </Select>
}}>
{(['ms', 's'] as const).map((u) => (
<Box
key={u}
onClick={() => setUnit(u)}
sx={{
flex: 1,
py: 0.8,
textAlign: 'center',
borderRadius: 3,
cursor: 'pointer',
fontSize: '0.75rem',
fontWeight: 800,
transition: 'all 0.2s',
bgcolor: unit === u ? '#fff' : 'transparent',
color: unit === u ? 'primary.main' : 'text.disabled',
boxShadow: unit === u ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
}}
>
{u === 'ms' ? '毫秒 (ms)' : '秒 (s)'}
</Box>
))}
</Box>
<Select <Select
fullWidth value={zone} fullWidth value={zone}
onChange={(e) => setZone(e.target.value as ZoneType)} onChange={(e) => { setZone(e.target.value as ZoneType); }}
sx={{ ...INPUT_STYLE, flex: 1 }} sx={{ ...INPUT_STYLE, flex: 1.5 }}
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' } } }} MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
> >
{ZONES.map((z) => ( {ZONES.map((z) => (
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{z}</MenuItem> <MenuItem key={z} value={z}>{z}</MenuItem>
))} ))}
</Select> </Select>
</Stack> </Stack>
</Stack> </Stack>
{/* Main Action */} {/* 4. 转换操作 (作为手动确认) */}
<Button <Button
fullWidth fullWidth variant="contained" disableElevation disableRipple
variant="contained"
onClick={convert} onClick={convert}
sx={{ sx={{
py: 1.4, py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
borderRadius: 4, bgcolor: 'primary.main', transition: 'all 0.2s',
bgcolor: 'primary.main', '&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
fontWeight: 800, '&:active': { transform: 'translateY(0)' }
fontSize: '0.9rem',
boxShadow: 'none',
'&:hover': { bgcolor: 'primary.dark', boxShadow: `0 8px 24px ${alpha('#2196f3', 0.2)}` }
}} }}
> >
</Button> </Button>
{/* Result View */} {/* 5. 结果展示 */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} /> <ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
</Container> </Paper>
<GlobalSnackbar {...snackbarProps} /> <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> </Box>
); );
} }
-91
View File
@@ -1,91 +0,0 @@
/* 极简滚动条变量 */
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
/* 滚动条变量 */
--sb-width: 6px;
--sb-thumb-color: rgba(0, 0, 0, 0.1);
--sb-thumb-hover: rgba(0, 0, 0, 0.2);
--sb-track-color: transparent;
}
/* 适配侧边栏 - 使用百分比而非固定尺寸 */
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
overflow: hidden; /* 禁用外层滚动 */
}
/* 全局极简滚动条定制 */
*::-webkit-scrollbar {
width: var(--sb-width);
}
*::-webkit-scrollbar-track {
background: var(--sb-track-color);
}
*::-webkit-scrollbar-thumb {
background: var(--sb-thumb-color);
border-radius: 10px;
background-clip: content-box;
border: 1px solid transparent;
}
*::-webkit-scrollbar-thumb:hover {
background: var(--sb-thumb-hover);
}
.app {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow: hidden; /* 内部由 flex 子项控制滚动 */
}
/* Animations */
@keyframes slideInRight {
from {
transform: translateX(30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.page-transition-enter {
animation: slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
.page-transition-dashboard {
animation: fadeIn 0.3s ease-out forwards;
}
-21
View File
@@ -1,21 +0,0 @@
import './App.css';
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/components/TopBar';
import RouterContainer from '@/components/RouterContainer';
function App() {
const handleOpenOptions = () => {
chrome.runtime.openOptionsPage();
};
return (
<RouterProvider defaultRoute="dashboard">
<div className="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
<TopBar onOpenOptions={handleOpenOptions} />
<RouterContainer />
</div>
</RouterProvider>
);
}
export default App;
-12
View File
@@ -1,12 +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 - Side Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
-10
View File
@@ -1,10 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './style.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
-22
View File
@@ -1,22 +0,0 @@
body {
margin: 0;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', '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;
}
-123
View File
@@ -1,123 +0,0 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import { getDefaultVisibleRoutes } from '@/config/routes';
interface RouterContextType {
currentPage: PageType;
visiblePages: PageType[];
isLoaded: boolean;
navigateTo: (page: PageType) => void;
navigateLocal: (page: PageType) => void;
syncNavigation: (page: PageType) => void;
goBack: () => void;
setVisiblePages: (pages: PageType[]) => void;
}
const RouterContext = createContext<RouterContextType | null>(null);
interface RouterProviderProps {
children: ReactNode;
defaultRoute?: PageType;
syncRoute?: boolean;
}
export function RouterProvider({
children,
defaultRoute = 'dashboard',
syncRoute = true
}: RouterProviderProps) {
const [currentPage, setCurrentPage] = useState<PageType>(defaultRoute);
const [visiblePages, setVisiblePages] = useState<PageType[]>(getDefaultVisibleRoutes());
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
loadInitialData();
}, []);
useEffect(() => {
if (isLoaded && syncRoute) {
storageUtil.set('app/currentRoute', currentPage);
}
}, [currentPage, isLoaded, syncRoute]);
// Listen for storage changes if sync is enabled
useEffect(() => {
if (!syncRoute) return;
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes['app/currentRoute']) {
const newRoute = changes['app/currentRoute'].newValue as PageType;
if (newRoute && newRoute !== currentPage) {
setCurrentPage(newRoute);
}
}
};
chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [syncRoute, currentPage]);
const loadInitialData = async () => {
try {
const [savedRoute, savedVisiblePages] = await Promise.all([
storageUtil.get('app/currentRoute', defaultRoute),
storageUtil.get('app/visiblePages', getDefaultVisibleRoutes()),
]);
if (savedRoute && syncRoute) {
setCurrentPage(savedRoute);
}
if (savedVisiblePages) {
setVisiblePages(savedVisiblePages);
}
} catch (error) {
console.error('Failed to load initial routing data:', error);
} finally {
setIsLoaded(true);
}
};
const navigateTo = (page: PageType) => {
setCurrentPage(page);
};
const navigateLocal = (page: PageType) => {
setCurrentPage(page);
};
const syncNavigation = (page: PageType) => {
storageUtil.set('app/currentRoute', page);
};
const goBack = () => {
setCurrentPage('dashboard');
};
return (
<RouterContext.Provider
value={{
currentPage,
visiblePages,
isLoaded,
navigateTo,
navigateLocal,
syncNavigation,
goBack,
setVisiblePages
}}
>
{children}
</RouterContext.Provider>
);
}
export function useRouter() {
const context = useContext(RouterContext);
if (!context) {
throw new Error('useRouter must be used within a RouterProvider');
}
return context;
}
export default RouterProvider;
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+14 -9524
View File
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+15 -4
View File
@@ -1,14 +1,19 @@
{ {
"extends": "./.wxt/tsconfig.json", "extends": "./.wxt/tsconfig.json",
"compilerOptions": { "compilerOptions": {
/* --- --- */
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"esModuleInterop": true, "esModuleInterop": true,
"module": "ESNext", // 支持 import.meta "module": "ESNext", // 支持 import.meta
"moduleResolution": "Bundler", // 或者用 "Node" "moduleResolution": "Bundler", // 或者用 "Node"
/* --- 1. () --- */
// 开启所有严格检查,包括 noImplicitAny。
// 这能帮你捕获 "timer" 隐式 any 等错误,强制你写出更高质量的代码。
"strict": true, "strict": true,
/* --- --- */ /* --- 2. --- */
// 声明了但没使用的变量报错(防止代码冗余) // 声明了但没使用的变量报错(防止代码冗余)
"noUnusedLocals": true, "noUnusedLocals": true,
// 函数参数没使用报错 // 函数参数没使用报错
@@ -18,22 +23,28 @@
// switch 语句没有 break 时报错 // switch 语句没有 break 时报错
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
/* --- --- */ /* --- 3. --- */
// 设置基础目录,方便解析相对路径
"baseUrl": ".",
// 确保包含 DOM 类型(解决 setTimeout、document 等报错) // 确保包含 DOM 类型(解决 setTimeout、document 等报错)
"lib": ["DOM", "DOM.Iterable", "ESNext"], "lib": ["DOM", "DOM.Iterable", "ESNext"],
// 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容 // 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容
"target": "ESNext", "target": "ESNext",
/* --- 4. () --- */
// 如果你的 @/utils/... 爆红,可以手动添加这个映射。
// WXT 通常会自动处理,但在这里显式声明有助于 VS Code 智能提示。
"paths": {
"@/*": ["./*"]
},
"types": ["chrome", "webextension-polyfill"], "types": ["chrome", "webextension-polyfill"],
"noImplicitAny": false "noImplicitAny": false
}, },
// 确保包含你的源代码目录 // 确保包含你的源代码目录
"include": [ "include": [
"vite-env.d.ts",
"entrypoints/**/*", "entrypoints/**/*",
"components/**/*", "components/**/*",
"utils/**/*", "utils/**/*",
"types/**/*",
"assets/**/*", "assets/**/*",
"hooks/**/*", "hooks/**/*",
".wxt/types/**/*.ts", ".wxt/types/**/*.ts",
+1 -12
View File
@@ -1,4 +1,4 @@
export type PageType = 'dashboard' | 'timestamp' | 'storageCleaner' | 'openUrl' | 'openUrlViewer'; export type PageType = 'timestamp' | 'storageCleaner';
export interface StorageSchema { export interface StorageSchema {
'app/currentRoute': PageType; 'app/currentRoute': PageType;
@@ -6,8 +6,6 @@ export interface StorageSchema {
'app/lastRoute': string; 'app/lastRoute': string;
'app/theme': string; 'app/theme': string;
'storageCleaner/preferences': StorageCleanerPreferences; 'storageCleaner/preferences': StorageCleanerPreferences;
'openUrl/preferences': OpenUrlPreferences;
'openUrl/currentUrl': string;
} }
export interface StorageCleanerPreferences { export interface StorageCleanerPreferences {
@@ -15,15 +13,6 @@ export interface StorageCleanerPreferences {
selectedTypes: StorageCleanerOptions; selectedTypes: StorageCleanerOptions;
} }
export interface OpenUrlEntry {
name: string;
url: string;
}
export interface OpenUrlPreferences {
entries: OpenUrlEntry[];
}
export interface StorageCleanerOptions { export interface StorageCleanerOptions {
localStorage: boolean; localStorage: boolean;
sessionStorage: boolean; sessionStorage: boolean;
+1 -1
View File
@@ -1,4 +1,4 @@
import { StorageSchema } from '@/types/storage'; import { StorageSchema } from 'types/storage';
class StorageUtils { class StorageUtils {
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>; async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
+1 -46
View File
@@ -1,4 +1,4 @@
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from '@/types/storage'; import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
const RESTRICTED_PROTOCOLS = [ const RESTRICTED_PROTOCOLS = [
'chrome:', 'chrome:',
@@ -20,51 +20,6 @@ export function isRestrictedUrl(url?: string): boolean {
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p)); return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
} }
export async function getCookieSize(url: string): Promise<number> {
try {
const cookies = await chrome.cookies.getAll({ url });
return cookies.reduce((acc, c) => acc + c.name.length + c.value.length, 0);
} catch {
return 0;
}
}
export async function getLocalStorageSize(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
return Object.entries(localStorage).reduce((acc, [k, v]) => acc + k.length + v.length, 0);
},
});
return (result?.result as number) || 0;
} catch {
return 0;
}
}
export async function getSessionStorageSize(tabId: number): Promise<number> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
return Object.entries(sessionStorage).reduce((acc, [k, v]) => acc + k.length + v.length, 0);
},
});
return (result?.result as number) || 0;
} catch {
return 0;
}
}
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export async function clearCookies(url: string): Promise<StorageCleanResult> { export async function clearCookies(url: string): Promise<StorageCleanResult> {
try { try {
const cookies = await chrome.cookies.getAll({ url }); const cookies = await chrome.cookies.getAll({ url });
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
+7 -5
View File
@@ -14,20 +14,22 @@ export default defineConfig({
'activeTab', 'activeTab',
'scripting', 'scripting',
'tabs', 'tabs',
'debugger',
'cookies', 'cookies',
'sidePanel',
], ],
host_permissions: ['<all_urls>'], host_permissions: ['<all_urls>'],
action: { action: {
default_title: 'Testing Tools', default_title: 'Testing Tools',
}, },
side_panel: {
default_path: 'entrypoints/sidepanel/index.html',
},
options_ui: { options_ui: {
page: 'entrypoints/options/index.html',
open_in_tab: true, open_in_tab: true,
}, },
// 将 favicon.ico 放入 public/ 文件夹中
icons: {
16: 'favicon.ico',
48: 'favicon.ico',
128: 'favicon.ico',
},
}, },
vite: () => ({ vite: () => ({
build: { build: {