Compare commits

...

62 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
71 changed files with 9078 additions and 16686 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(npm install:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(npm run:*)",
"Bash(git show-ref:*)",
"Bash(git checkout:*)",
"mcp__plugin_playwright_playwright__browser_navigate"
]
}
}
-29
View File
@@ -1,29 +0,0 @@
{
"env": {
"browser": true,
"es2021": true,
"webextensions": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"no-undef": "error"
},
"globals": {
"chrome": "readonly"
}
}
+31
View File
@@ -0,0 +1,31 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm install
- run: npm run build --if-present
# - run: npm test
+23 -21
View File
@@ -1,25 +1,27 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.idea
node_modules
.output
stats.html
stats-*.json
.wxt
.vitest
.claude
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+12 -11
View File
@@ -3,17 +3,18 @@
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"singleQuote": true,
"jsxSingleQuote": false,
"trailingComma": "none",
"trailingComma": "all",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"arrowParens": "always",
"requirePragma": false,
"insertPragma": false,
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "ignore",
"endOfLine": "auto"
}
"endOfLine": "lf",
"overrides": [
{
"files": "*.json",
"options": {
"trailingComma": "none"
}
}
]
}
+143
View File
@@ -0,0 +1,143 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
## 核心命令
### 开发相关
- `npm run dev` - 启动 Chrome 浏览器的开发模式
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展
- `npm run zip:firefox` - 打包 Firefox 扩展
- `npm run compile` - TypeScript 类型检查(不生成文件)
- `npm run lint` - 运行 ESLint 检查
### 测试相关
- `npm run test` - 运行所有测试(单次执行)
- `npm run test:watch` - 运行测试并监听文件变化
- `npm run test:coverage` - 运行测试并生成覆盖率报告
**运行单个测试文件:**
```bash
npx vitest run components/__tests__/CopyButton.test.tsx
```
**测试技术栈:**
- Vitest - 测试框架
- @testing-library/react - React 组件测试
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent
- jsdom - 浏览器环境模拟
### 依赖与准备
- `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
## 项目架构
### 技术栈
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI (MUI)
- **状态管理**: React Hooks
- **路由**: React Router DOM
### 目录结构
```
├── components/ # 可复用 UI 组件
│ ├── CopyButton.tsx # 复制按钮组件
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
│ ├── Navbar.tsx # 导航栏组件
│ ├── RoutePersistence.tsx # 路由持久化组件
│ ├── TimestampExecution.tsx # 时间戳执行组件
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(主进程)
│ ├── content.ts # 内容脚本(注入到页面)
│ └── popup/ # 扩展弹窗界面
│ ├── App.tsx # 弹窗主应用
│ ├── main.tsx # 弹窗入口
│ └── pages/ # 弹窗页面
│ ├── TestPage.tsx # 测试页面
│ └── TimestampPage.tsx # 时间戳工具页面
├── utils/ # 工具函数
│ ├── chromeStorage.ts # Chrome 存储工具
│ ├── dayjs.ts # 日期处理工具
│ └── messages.tsx # 消息通信工具
├── types/ # 类型定义
│ └── storage.d.ts # 存储相关类型
```
### 核心功能实现
#### 1. 时间戳转换工具
- 位置: `components/` 目录下的时间戳相关组件
- 依赖: dayjs 库进行日期处理
- 功能: 支持日期与时间戳的双向转换,支持多种格式
#### 2. 通信系统
- 位置: `utils/messages.tsx`
- 机制: 使用 `@webext-core/messaging` 库实现
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
#### 3. 数据存储
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
### 关键配置文件
#### wxt.config.ts
- 配置 WXT 框架参数
- 启用 React 模块
- 配置浏览器扩展权限
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
#### manifest 权限
```typescript
permissions: [
'storage', // 存储权限
'unlimitedStorage', // 无限制存储
'clipboardWrite', // 剪贴板写入
'activeTab', // 当前标签页
'scripting', // 脚本注入
'tabs', // 标签页管理
'debugger', // 调试器
],
host_permissions: ['<all_urls>'] // 访问所有网站
```
## 开发注意事项
### 扩展入口点
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
### 代码质量
- 使用 ESLint 进行代码检查
- Husky 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范
+171
View File
@@ -0,0 +1,171 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
## 核心命令
### 开发相关
- `npm run dev` - 启动 Chrome 浏览器的开发模式
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
- `npm run build` - 构建 Chrome 浏览器的生产版本
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
- `npm run zip` - 打包 Chrome 扩展
- `npm run zip:firefox` - 打包 Firefox 扩展
- `npm run compile` - TypeScript 类型检查(不生成文件)
- `npm run lint` - 运行 ESLint 检查
### 依赖与准备
- `npm install` - 安装依赖
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
- `prepare` 钩子会初始化 Husky Git 钩子
### CI/CD
- GitHub Actions 配置: `.github/workflows/node.js.yml`
- 在 main 分支推送或 PR 时触发
- 使用 Node.js 22.x 运行 build
- 测试命令当前被注释(项目暂无测试)
## 项目架构
### 技术栈
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI (MUI)
- **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging
### 目录结构
```
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
│ ├── popup/ # 扩展弹窗界面
│ │ ├── App.tsx # 弹窗主应用
│ │ ├── main.tsx # 弹窗入口
│ │ ├── index.html # 弹窗 HTML
│ │ └── pages/ # 弹窗页面
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
│ └── options/ # 选项页面(当前为静态 HTML)
│ └── index.html # 选项页 HTML
├── utils/ # 工具函数
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging
├── types/ # 类型定义
│ └── storage.d.ts # StorageSchema 类型定义
├── constants/ # 常量定义(当前为空)
└── public/ # 静态资源
```
### 核心功能
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
- 实时显示当前时间戳(毫秒/秒可切换)
- 时间戳 → 日期时间转换
- 日期时间 → 时间戳转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制功能
- 输入验证和错误提示
### 扩展入口点
- **后台脚本** (`entrypoints/background.ts`):
- 监听扩展安装/更新事件
- 自动向所有有效标签页注入内容脚本
- 过滤受限协议(chrome://, about:// 等)
- **内容脚本** (`entrypoints/content.ts`):
- 匹配所有 URL (`<all_urls>`)
- 在文档开始时运行
- 当前为占位符,无实际逻辑
- **弹窗** (`entrypoints/popup/`):
- 主入口显示 TimestampPage
- 提供时间戳转换的完整功能
- **选项页** (`entrypoints/options/`):
- 当前为静态 HTML 页面
- 可扩展为设置界面
### 数据存储
使用 Chrome Storage API 进行持久化存储:
- 类型安全的封装 (`utils/chromeStorage.ts`)
- 基于接口定义的 Schema (`types/storage.d.ts`)
- 当前支持的存储键:
- `app/lastRoute`: 上次访问的路由
- `app/theme`: 主题设置
### 消息通信
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
- 定义在 `utils/messages.tsx`
- 当前 ProtocolMap 为空(预留接口)
### 关键配置文件
#### wxt.config.ts
- 启用 React 模块 (`@wxt-dev/module-react`)
- 配置 manifest 权限和 host_permissions
- 使用 Terser 压缩(强制 ASCII 编码)
- 配置图标和选项页
#### manifest 权限
```typescript
permissions: [
'storage', // Chrome Storage
'unlimitedStorage', // 无限制存储
'clipboardWrite', // 剪贴板写入(复制功能)
'activeTab', // 当前标签页访问
'scripting', // 脚本注入
'tabs', // 标签页管理
'debugger', // 调试器权限
],
host_permissions: ['<all_urls>'] // 访问所有网站
```
## 开发注意事项
### 浏览器兼容性
- 支持 Chrome 和 Firefox 浏览器
- 使用 WXT 框架抽象浏览器差异
### 代码质量
- 使用 ESLint 进行代码检查(零警告)
- Husky 用于 Git 钩子管理
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier
- Prettier 用于代码格式化
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
### TypeScript 配置
- 严格模式开启(`strict: true`
- `noImplicitAny` 设置为 `false`(允许隐式 any
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`
- 模块解析模式:Bundler
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
### 项目历史
近期重构(根据 git 历史):
- 移除了录制回放功能
- 移除了测试页面
- 精简为单页面时间戳工具
- 将 storage 工具类重命名为 storageUtil
+72
View File
@@ -0,0 +1,72 @@
# Testing Tools 项目指南
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
## 1. 项目概览
- **名称**: Testing Tools
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
## 2. 项目结构
```text
├── .github/ # CI/CD 工作流
├── .husky/ # Git Hooks (pre-commit linting)
├── assets/ # 静态资源 (SVG 等)
├── components/ # 复用 UI 组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台 Service Worker 逻辑
│ ├── content.ts # 内容脚本注入逻辑
│ ├── popup/ # 扩展弹出层 (主要功能区)
│ └── options/ # 扩展选项页面
├── types/ # 全局 TypeScript 类型声明
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
├── wxt.config.ts # WXT 框架与 Manifest 配置
└── package.json # 依赖管理与脚本
```
## 3. 开发规范与风格约定
### 3.1 UI 设计语言
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
- **MUI 定制**:
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
- **布局**: 优先使用 `Stack``Box` 进行布局,确保自上而下的操作流顺畅。
### 3.2 技术选型惯例
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
- **状态管理**: 优先使用 React 原生 `useState``useMemo`
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
## 4. 关键指令
### 4.1 开发与调试
- `npm run dev`: 启动 Chrome 扩展开发模式。
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
### 4.2 构建与检查
- `npm run build`: 构建生产版本。
- `npm run compile`: TypeScript 类型检查。
- `npm run lint`: ESLint 代码风格检查。
## 5. 权限与清单 (Manifest)
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
## 6. 维护者提示
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
+89 -38
View File
@@ -1,70 +1,121 @@
# Getting Started with Create React App
# Testing Tools Browser Extension
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。
## Available Scripts
## 项目概述
In the project directory, you can run:
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
### `npm start`
## 功能特性
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
### 时间戳转换工具
The page will reload when you make changes.\
You may also see any lint errors in the console.
- 实时显示当前时间戳(毫秒/秒可切换)
- 日期与时间戳之间的双向转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制转换结果
- 输入验证和错误提示
### `npm test`
## 技术栈
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
- **框架**: WXT (Web Extension Toolkit)
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI
- **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装)
### `npm run build`
## 项目结构
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
```
├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
│ ├── options/ # 选项页面
│ ├── background.ts # 后台脚本
│ └── content.ts # 内容脚本
├── utils/ # 工具函数(存储、日期处理、消息通信)
├── types/ # TypeScript 类型定义
├── public/ # 静态资源
├── wxt.config.ts # WXT 配置文件
├── package.json # 项目依赖和脚本
└── README.md # 项目说明文档
```
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
## 开发环境要求
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
- Node.js >= 18
- npm 或 yarn
### `npm run eject`
## 安装与运行
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
### 1. 安装依赖
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
```bash
npm install
```
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
### 2. 开发模式
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
```bash
# Chrome 浏览器
npm run dev
## Learn More
# Firefox 浏览器
npm run dev:firefox
```
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
### 3. 构建生产版本
To learn React, check out the [React documentation](https://reactjs.org/).
```bash
# Chrome 浏览器
npm run build
### Code Splitting
# Firefox 浏览器
npm run build:firefox
```
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### 4. 打包分发
### Analyzing the Bundle Size
```bash
# Chrome 浏览器
npm run zip
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
# Firefox
npm run zip:firefox
```
### Making a Progressive Web App
### 5. 其他命令
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
```bash
npm run compile # TypeScript 类型检查
npm run lint # ESLint 代码检查
```
### Advanced Configuration
## 权限说明
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
扩展请求以下权限:
### Deployment
- `storage``unlimitedStorage` - 本地数据存储
- `clipboardWrite` - 剪贴板写入(复制功能)
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
- `debugger` - 调试器权限
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
## 主要依赖
### `npm run build` fails to minify
- `react`, `react-dom` - 前端框架
- `@mui/material` - UI 组件库
- `dayjs` - 日期处理
- `@webext-core/messaging` - 扩展消息通信
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
此项目为私有项目 (private: true),仅供内部使用。
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,880 @@
# Storage Cleaner Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a storage cleaner feature to the browser extension popup that allows users to clear localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, andress Workers for the current page.
**Architecture:** Add new StorageCleanerPage component with tab switching in the popup, using chrome.cookies API for cookies and script injection for other storage types. User preferences are persisted using Chrome Storage.
**Tech Stack:** React 19 + TypeScript, Material UI, Chrome Extension APIs
---
## File Structure
```
entrypoints/popup/
├── App.tsx (modify: add tab switching)
└── pages/
├── TimestampPage.tsx (no change)
└── StorageCleanerPage.tsx (create: new storage cleaner page)
types/
└── storage.d.ts (modify: add storage cleaner types)
utils/
└── storageCleaner.ts (create: storage cleaning utilities)
wxt.config.ts (modify: add cookies permission)
```
---
## Task 1: Add TypeScript Types for Storage Cleaner
**Files:**
- Modify: `types/storage.d.ts`
- [ ] **Step 1: Add storage cleaner types to StorageSchema and interfaces**
```typescript
export interface StorageSchema {
'app/lastRoute': string;
'app/theme': string;
'storageCleaner/preferences': StorageCleanerPreferences;
}
export interface StorageCleanerPreferences {
autoRefresh: boolean;
selectedTypes: StorageCleanerOptions;
}
export interface StorageCleanerOptions {
localStorage: boolean;
sessionStorage: boolean;
indexedDB: boolean;
cookies: boolean;
cacheStorage: boolean;
serviceWorkers: boolean;
}
export type StorageCleanResult =
| {
success: true;
count: number;
}
| {
success: false;
error: string;
};
export interface CleaningResult {
success: boolean;
error?: string;
localStorage?: StorageCleanResult;
sessionStorage?: StorageCleanResult;
indexedDB?: StorageCleanResult;
cookies?: StorageCleanResult;
cacheStorage?: StorageCleanResult;
serviceWorkers?: StorageCleanResult;
}
```
- [ ] **Step 2: Commit TypeScript types**
```bash
git add types/storage.d.ts
git commit -m "feat: add TypeScript types for storage cleaner"
```
---
## Task 2: Add Cookies Permission to Manifest
**Files:**
- Modify: `wxt.config.ts:10-18`
- [ ] **Step 1: Add 'cookies' permission to manifest**
```typescript
permissions: [
'storage',
'unlimitedStorage',
'clipboardWrite',
'activeTab',
'scripting',
'tabs',
'debugger',
'cookies', // Add this line
],
```
- [ ] **Step 2: Test build to ensure manifest is valid**
Run: `npm run compile`
Expected: No TypeScript errors
- [ ] **Step 3: Commit manifest changes**
```bash
git add wxt.config.ts
git commit -m "feat: add cookies permission to manifest"
```
---
## Task 3: Create Storage Cleaning Utilities
**Files:**
- Create: `utils/storageCleaner.ts`
- [ ] **Step 1: Create storage cleaning utility file with helper functions**
```typescript
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
const RESTRICTED_PROTOCOLS = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
] as const;
export async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
export function isRestrictedUrl(url?: string): boolean {
if (!url) return true;
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
export async function clearCookies(url: string): Promise<StorageCleanResult> {
try {
const cookies = await chrome.cookies.getAll({ url });
for (const cookie of cookies) {
await chrome.cookies.remove({
url,
name: cookie.name,
storeId: cookie.storeId,
});
}
return { success: true, count: cookies.length };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: () => {
const count = localStorage.length;
localStorage.clear();
return { count };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: () => {
const count = sessionStorage.length;
sessionStorage.clear();
return { count };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number } | { error: string }>({
target: { tabId },
func: () => {
if (typeof indexedDB.databases === 'function') {
return indexedDB.databases().then(async (databases) => {
let count = 0;
for (const db of databases) {
await new Promise<void>((resolve, reject) => {
const deleteReq = indexedDB.deleteDatabase(db.name);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', db.name);
};
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject();
});
count++;
}
return { count };
});
}
return { error: 'databases_api_unavailable' };
},
});
if (result.result) {
if ('error' in result.result) {
return { success: false, error: result.result.error };
}
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: async () => {
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
return { count: cacheNames.length };
}
return { count: 0 };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
try {
const result = await chrome.scripting.executeScript<{ count: number }>({
target: { tabId },
func: async () => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
return { count: registrations.length };
}
return { count: 0 };
},
});
if (result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function clearStorage(
tabId: number,
url: string,
options: StorageCleanerOptions,
): Promise<CleaningResult> {
const result: CleaningResult = { success: true };
if (options.localStorage) {
result.localStorage = await injectClearLocalStorage(tabId);
}
if (options.sessionStorage) {
result.sessionStorage = await injectClearSessionStorage(tabId);
}
if (options.indexedDB) {
result.indexedDB = await injectClearIndexedDB(tabId);
}
if (options.cookies) {
result.cookies = await clearCookies(url);
}
if (options.cacheStorage) {
result.cacheStorage = await injectClearCacheStorage(tabId);
}
if (options.serviceWorkers) {
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
}
// Check if any operation failed
const failures = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === false,
);
if (failures.length > 0) {
result.success = false;
result.error = '部分清理失败';
}
return result;
}
export function formatCleaningResult(result: CleaningResult): string {
const parts: string[] = [];
if (result.localStorage?.success) {
parts.push(`${result.localStorage.count} 个 localStorage`);
}
if (result.sessionStorage?.success) {
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
}
if (result.indexedDB?.success) {
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
}
if (result.cookies?.success) {
parts.push(`${result.cookies.count} 个 Cookies`);
}
if (result.cacheStorage?.success) {
parts.push(`${result.cacheStorage.count} 个 Cache`);
}
if (result.serviceWorkers?.success) {
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
}
if (parts.length === 0) {
return '该页面没有可清理的存储数据';
}
return `清理了 ${parts.join(', ')}`;
}
export function isEmptyResult(result: CleaningResult): boolean {
const values = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
);
return values.length === 0;
}
```
- [ ] **Step 2: Commit storage cleaning utilities**
```bash
git add utils/storageCleaner.ts
git commit -m "feat: add storage cleaning utility functions"
```
---
## Task 4: Create StorageCleanerPage Component
**Files:**
- Create: `entrypoints/popup/pages/StorageCleanerPage.tsx`
- [ ] **Step 1: Create StorageCleanerPage component with UI and logic**
```typescript
import { useState, useEffect, useCallback } from 'react';
import {
Paper,
Typography,
Box,
Checkbox,
Button,
FormControlLabel,
Alert,
Snackbar,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning';
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
import {
getCurrentTab,
isRestrictedUrl,
clearStorage,
formatCleaningResult,
isEmptyResult,
} from '@/utils/storageCleaner';
const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
};
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
autoRefresh: true,
selectedTypes: DEFAULT_OPTIONS,
};
export default function StorageCleanerPage() {
const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>('');
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
open: false,
message: '',
});
// Load tab info and user preferences
useEffect(() => {
const loadInfo = async () => {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
setError('无法获取当前标签页');
return;
}
if (isRestrictedUrl(tab.url)) {
setError('存储清理功能不支持此页面');
return;
}
setDomain(new URL(tab.url).hostname);
// Load user preferences
const prefs = await storageUtil.get(
'storageCleaner/preferences',
DEFAULT_PREFERENCES,
);
setAutoRefresh(prefs.autoRefresh);
setOptions(prefs.selectedTypes);
};
loadInfo();
}, []);
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const handleClean = useCallback(async () => {
const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) {
setSnackbar({ open: true, message: '无法获取当前标签页' });
return;
}
setLoading(true);
try {
const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult);
// Save user preferences
await storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: options,
});
// Auto refresh if enabled
if (autoRefresh && cleaningResult.success) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id);
}, 1500);
}
} catch (err) {
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
} finally {
setLoading(false);
setShowConfirm(false);
}
}, [options, autoRefresh]);
const handleRefresh = useCallback(async () => {
const tab = await getCurrentTab();
if (tab?.id) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id);
}, 1500);
}
}, []);
if (error) {
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Alert severity="error" icon={<WarningIcon />}>
{error}
</Alert>
</Paper>
);
}
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
: {domain || '加载中...'}
</Typography>
</Box>
{/* Storage Type Options */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Checkbox
checked={options.localStorage}
onChange={() => handleOptionChange('localStorage')}
/>
}
label="localStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.sessionStorage}
onChange={() => handleOptionChange('sessionStorage')}
/>
}
label="sessionStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.indexedDB}
onChange={() => handleOptionChange('indexedDB')}
/>
}
label="IndexedDB"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cookies}
onChange={() => handleOptionChange('cookies')}
/>
}
label="Cookies"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cacheStorage}
onChange={() => handleOptionChange('cacheStorage')}
/>
}
label="Cache Storage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.serviceWorkers}
onChange={() => handleOptionChange('serviceWorkers')}
/>
}
label="Service Workers"
/>
</Box>
</Box>
{/* Auto Refresh Option */}
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
}
label="清理完成后自动刷新页面"
/>
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
onClick={() => setShowConfirm(true)}
disabled={loading}
fullWidth
>
{loading ? '清理中...' : '清理'}
</Button>
</Box>
{/* Result Display */}
{result && (
<Box sx={{ mb: 2 }}>
<Alert
severity={result.success ? 'success' : 'error'}
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
>
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
</Alert>
{!autoRefresh && result.success && (
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={handleRefresh}
fullWidth
>
</Button>
)}
</Box>
)}
{/* Confirmation Dialog */}
{showConfirm && (
<Paper
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255, 255, 255, 0.95)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
zIndex: 10,
}}
>
<Typography variant="h6"></Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
</Typography>
<Box sx={{ mb: 1 }}>
{options.localStorage && (
<Typography variant="body2">- localStorage</Typography>
)}
{options.sessionStorage && (
<Typography variant="body2">- sessionStorage</Typography>
)}
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
{options.cacheStorage && (
<Typography variant="body2">- Cache Storage</Typography>
)}
{options.serviceWorkers && (
<Typography variant="body2">- Service Workers</Typography>
)}
</Box>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', mb: 1 }}
>
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
</Button>
<Button variant="contained" color="error" onClick={handleClean}>
</Button>
</Box>
</Paper>
)}
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity="info" variant="filled">
{snackbar.message}
</Alert>
</Snackbar>
</Paper>
);
}
```
- [ ] **Step 2: Commit StorageCleanerPage component**
```bash
git add entrypoints/popup/pages/StorageCleanerPage.tsx
git commit -m "feat: add StorageCleanerPage component"
```
---
## Task 5: Update App.tsx with Tab Switching
**Files:**
- Modify: `entrypoints/popup/App.tsx`
- [ ] **Step 1: Add tab switching logic to App.tsx**
```typescript
import { useState } from 'react';
import { Box, Button } from '@mui/material';
import TimestampPage from './pages/TimestampPage';
import StorageCleanerPage from './pages/StorageCleanerPage';
import './App.css';
type PageType = 'timestamp' | 'storageCleaner';
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
return (
<div className="app">
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<Button
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('timestamp')}
>
</Button>
<Button
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('storageCleaner')}
sx={{ ml: 1 }}
>
</Button>
</Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
);
}
export default App;
```
- [ ] **Step 2: Run type check**
Run: `npm run compile`
Expected: No TypeScript errors
- [ ] **Step 3: Commit App.tsx changes**
```bash
git add entrypoints/popup/App.tsx
git commit -m "feat: add tab switching to App component"
```
---
## Task 6: Build and Test
**Files:**
- No file changes
- [ ] **Step 1: Build the extension**
Run: `npm run build`
Expected: Build succeeds with no errors
- [ ] **Step 2: Run lint check**
Run: `npm run lint`
Expected: No linting errors
- [ ] **Step 3: Load extension in Chrome for manual testing**
Instructions:
1. Open Chrome and navigate to `chrome://extensions/`
2. Enable Developer Mode
3. Click "Load unpacked"
4. Select `.output/chrome-mv3` directory
5. Test on a regular web page (e.g., example.com)
- [ ] **Step 4: Commit successful implementation**
```bash
git commit --allow-empty -m "feat: complete storage cleaner feature implementation"
```
---
## Testing Checklist
After implementation, verify:
- [ ] Tab switching works between timestamp and storage cleaner
- [ ] Current domain displays correctly
- [ ] All storage type checkboxes toggle correctly
- [ ] Auto refresh checkbox persists across sessions
- [ ] Clear confirmation dialog appears
- [ ] Confirmation dialog shows selected storage types
- [ ] localStorage clears successfully
- [ ] sessionStorage clears successfully
- [ ] IndexedDB clears successfully (or shows error if unavailable)
- [ ] Cookies clear successfully
- [ ] Clear httponly and secure cookies
- [ ] Cache Storage clears successfully
- [ ] Service Workers unregister successfully
- [ ] Result message displays correctly
- [ ] Empty state shows friendly message
- [ ] Auto refresh works
- [ ] Manual refresh button appears when auto-refresh is off
- [ ] Restricted pages show error message
- [ ] Snackbar notifications appear correctly
- [ ] Test on localhost
---
## Rollback Plan
If issues occur during testing:
1. Revert to before implementation:
```bash
git reset --hard <commit-before-start>
```
2. Or revert specific files:
```bash
git checkout HEAD -- types/storage.d.ts wxt.config.ts utils/storageCleaner.ts entrypoints/popup/App.tsx entrypoints/popup/pages/StorageCleanerPage.tsx
```
---
## Notes
- The popup closes automatically when the page is refreshed - this is expected behavior
- IndexedDB.databases() may not be available in all browser versions; the fallback handles this
- Chrome Cookies API requires explicit permission, which is added to the manifest
- User preferences are persisted using the existing chromeStorage.ts utility
@@ -0,0 +1,387 @@
# 存储清理功能设计文档
## 概述
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers。
## 目标
- 提供便捷的页面存储清理功能
- 支持多种存储类型清理
- 提供清理结果反馈
- 支持清理后自动刷新页面
## 架构设计
### 组件结构
```
entrypoints/popup/pages/
├── TimestampPage.tsx (现有:时间戳转换页面)
└── StorageCleanerPage.tsx (新增:存储清理页面)
```
### 页面布局
在弹窗中添加标签页切换功能,用户可以在时间戳转换和存储清理之间切换。
**路由实现方案:**
使用简单的状态管理进行页面切换:
```typescript
// App.tsx
type PageType = 'timestamp' | 'storageCleaner';
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
return (
<div className="app">
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<Button
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('timestamp')}
>
</Button>
<Button
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
onClick={() => setCurrentPage('storageCleaner')}
sx={{ ml: 1 }}
>
</Button>
</Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
);
}
```
## 用户界面设计
### 页面组成
1. **头部区域**
- 标题:"存储清理"
- 当前域名显示(自动从活动标签页获取)
2. **存储类型选择区域**
- 勾选框:localStorage
- 勾选框:sessionStorage
- 勾选框:IndexedDB
- 勾选框:Cookies
- 勾选框:Cache Storage
- 勾选框:Service Workers
3. **自动刷新选项**
- 复选框:清理完成后自动刷新页面(默认勾选)
4. **操作区域**
- 清理按钮
5. **结果显示区域**
- 清理成功/失败提示
- 清理详情统计(如:"清理了 5 个 localStorage, 3 个 cookies"
- 刷新页面按钮(当未勾选自动刷新时显示)
## 技术实现细节
### 获取当前标签页域名
使用 Chrome Tabs API 获取当前活动标签页,并过滤受限页面:
```typescript
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
// 检查受限页面
const restrictedProtocols = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
];
if (!tab?.url || restrictedProtocols.some((p) => tab.url!.startsWith(p))) {
throw new Error('存储清理功能不支持此页面');
}
const domain = new URL(tab.url).hostname;
```
### 清理 Cookies(使用 chrome.cookies API
在扩展环境中直接执行,不需要注入页面:
```typescript
const cookies = await chrome.cookies.getAll({ url: tab.url });
let count = 0;
for (const cookie of cookies) {
await chrome.cookies.remove({
url: tab.url,
name: cookie.name,
storeId: cookie.storeId,
});
count++;
}
```
### 注入脚本清理其他存储
使用 `chrome.scripting.executeScript` 注入清理脚本:
```typescript
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
// 清理逻辑在页面上下文中执行
},
});
```
需要注入到页面执行的存储清理逻辑:
#### 清理 localStorage
```javascript
const count = localStorage.length;
localStorage.clear();
return count;
```
#### 清理 sessionStorage
```javascript
const count = sessionStorage.length;
sessionStorage.clear();
return count;
```
#### 清理 IndexedDB
```javascript
// 检查 indexedDB.databases 方法是否可用
if (typeof indexedDB.databases === 'function') {
const databases = await indexedDB.databases();
let count = 0;
for (const db of databases) {
const deleteReq = indexedDB.deleteDatabase(db.name);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', db.name);
};
await new Promise((resolve, reject) => {
deleteReq.onsuccess = resolve;
deleteReq.onerror = reject;
});
count++;
}
return count;
}
// 降级方案:由于无法获取所有数据库名称,提示返回特殊值表示需要手动操作
return { error: 'databases_api_unavailable' };
```
#### 清理 Cache Storage
```javascript
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
return cacheNames.length;
}
return 0;
```
#### 注销 Service Workers
```javascript
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
let count = 0;
for (const registration of registrations) {
await registration.unregister();
count++;
}
return count;
}
return 0;
```
### 数据流
1. 页面加载时获取当前标签页 URL 并显示域名
2. 检查是否为受限页面(chrome://, about:// 等),如果是则显示错误提示
3. 用户勾选要清理的存储类型
4. 用户选择是否自动刷新页面
5. 用户点击清理按钮
6. 弹出确认对话框询问用户确认
7. 确认后执行清理:
- 如果选择 Cookies:直接使用 chrome.cookies API 删除
- 其他存储类型:向页面注入清理脚本
8. 收集所有清理结果并统计
9. 显示清理结果
10. 如果勾选"自动刷新"或用户点击"刷新页面"按钮,执行页面刷新
**注意:** 当触发页面刷新时,popup 会自动关闭。需要在刷新前显示提示信息。
### 页面刷新
```typescript
// 显示刷新提示
setRefreshing(true);
setTimeout(async () => {
await chrome.tabs.reload(tab.id);
}, 1500); // 1.5秒延迟让用户看到提示信息
```
### 空状态处理
当所有存储类型清理返回 0 时,显示友好的提示:
```
该页面没有可清理的存储数据
```
## 错误处理
| 错误场景 | 处理方式 |
| ------------------------------- | ---------------------------------------- |
| 无法获取当前标签页 | 显示错误提示:"无法获取当前标签页" |
| 受限页面(chrome://, about:// | 显示错误提示:"存储清理功能不支持此页面" |
| 无法访问页面 URL | 显示错误提示:"无法访问此页面" |
| IndexedDB onblocked | 显示警告但继续执行其他清理 |
| IndexedDB.databases 不可用 | 使用降级方案或提示用户手动清除 |
| 清理失败 | 显示具体错误信息 |
| Cookies 删除失败 | 记录错误,显示清理失败提示 |
| 无权限 | 提示用户刷新扩展或检查权限 |
| 脚本注入失败 | 显示错误提示:"无法注入清理脚本" |
**Popup 生命周期说明:**
- Popup 在页面失去焦点时会关闭
- 刷新页面后 Popup 会自动关闭
- 需要在刷新前显示提示:"页面即将刷新,Popup 将关闭"
## 用户偏好持久化
使用现有的 `chromeStorage.ts` 工具保存用户偏好:
```typescript
// 保存用户偏好
await storageUtil.set('storageCleaner/preferences', {
autoRefresh: true, // 默认勾选自动刷新
selectedTypes: {
// 可以保存用户上次选择的存储类型
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
},
});
// 读取用户偏好
const preferences = await storageUtil.get('storageCleaner/preferences', {
autoRefresh: true,
selectedTypes: {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
},
});
```
## 权限需求
需要在 manifest 中添加 `cookies` 权限:
```typescript
permissions: [
'storage',
'unlimitedStorage',
'clipboardWrite',
'activeTab',
'scripting',
'tabs',
'debugger',
'cookies', // 新增
],
```
## TypeScript 类型定义
在现有 `types/storage.d.ts` 中添加存储清理相关的类型:
```typescript
export interface StorageSchema {
'app/lastRoute': string;
'app/theme': string;
'storageCleaner/preferences': StorageCleanerPreferences;
}
export interface StorageCleanerPreferences {
autoRefresh: boolean;
selectedTypes: StorageCleanerOptions;
}
export interface StorageCleanerOptions {
localStorage: boolean;
sessionStorage: boolean;
indexedDB: boolean;
cookies: boolean;
cacheStorage: boolean;
serviceWorkers: boolean;
}
export type StorageCleanResult =
| {
success: true;
count: number;
}
| {
success: false;
error: string;
};
export interface CleaningResult {
success: boolean;
error?: string;
localStorage?: StorageCleanResult;
sessionStorage?: StorageCleanResult;
indexedDB?: StorageCleanResult;
cookies?: StorageCleanResult;
cacheStorage?: StorageCleanResult;
serviceWorkers?: StorageCleanResult;
}
```
## 测试计划
1. 测试各种存储类型的单独清理
2. 测试同时清理多种存储类型
3. 测试自动刷新功能
4. 测试手动刷新按钮
5. 测试无存储数据时的清理(显示空状态提示)
6. 测试无法访问页面的错误处理
7. 测试受限页面(chrome://, about://, file://, data://
8. 测试 IndexedDB onblocked 场景
9. 测试 httponly 和 secure cookies 清理
10. 测试本地开发环境(localhost)
11. 测试用户偏好持久化
## 后续优化
- 显示清理前的存储使用情况
- 支持批量清理多个标签页
- 支持自定义域名清理
+51
View File
@@ -0,0 +1,51 @@
import '../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser';
export default defineBackground(() => {
// 监听扩展安装或更新事件
browser.runtime.onInstalled.addListener(async ({ reason }) => {
if (reason === 'install') {
console.log('Extension installed for the first time');
} else if (reason === 'update') {
console.log('Extension updated to a new version');
}
// 获取所有标签页
const tabs = await browser.tabs.query({});
// 过滤不合法或受限制的 URL
const targetTabs = tabs.filter((tab) => {
if (!tab.id || !tab.url) return false;
const restrictedProtocols = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
];
return !restrictedProtocols.some((protocol) => tab.url!.startsWith(protocol));
});
const results = await Promise.allSettled(
targetTabs.map((tab) =>
browser.scripting
.executeScript({
target: { tabId: tab.id! },
files: ['/content-scripts/content.js'],
})
.catch((err) => {
console.warn(`Failed to inject script into tab ${tab.id}:`, err.message);
}),
),
);
const successCount = results.filter((r) => r.status === 'fulfilled').length;
console.log(
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
);
});
chrome.tabs.onUpdated.addListener((tabId) => {
console.log('加载完成的 Tab ID:', tabId);
});
});
+9
View File
@@ -0,0 +1,9 @@
import '../.wxt/types/imports.d.ts';
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_start',
main() {
// Content script placeholder
},
});
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
Hello Options Page
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
/* 隐藏页面滚动条 */
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
scrollbar-gutter: stable;
}
/* 隐藏body滚动条但允许滚动 */
body {
overflow: auto;
}
:root {
/* 统一圆角变量 */
--radius: 8px;
/* 统一背景颜色变量 */
--bg-color: #fafafa;
/* 统一文字颜色变量 */
--text-color: #333333;
/* 统一按钮颜色变量 */
--btn-bg: #e0e0e0;
/* 统一按钮文字颜色变量 */
--btn-text: #333333;
--border: 1px solid #e0e0e0;
}
.app {
width: 400px;
min-height: 100%;
margin: 0 auto;
box-sizing: border-box;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
overflow-x: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 导航容器 */
.nav-container {
display: flex;
justify-content: center;
margin-top: 16px;
margin-bottom: 16px;
gap: 8px;
}
/* 导航按钮 */
.nav-button {
padding: 8px 16px;
border: 1px solid var(--btn-bg);
border-radius: var(--radius);
background: transparent;
color: var(--btn-text);
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
}
.nav-button:hover {
background: var(--btn-bg);
}
.nav-button.active {
background: var(--btn-bg);
font-weight: 500;
}
+88
View File
@@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import { Box } from '@mui/material';
import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage';
import TimestampPage from './pages/TimestampPage';
import StorageCleanerPage from './pages/StorageCleanerPage';
import './App.css';
const PAGE_CONFIG = {
timestamp: { label: '时间戳', defaultVisible: true },
storageCleaner: { label: '存储清理', defaultVisible: true },
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
function App() {
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
const [visiblePages, setVisiblePages] = useState<PageType[]>(['timestamp', 'storageCleaner']);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
loadInitialData();
}, []);
useEffect(() => {
if (isLoaded) {
storageUtil.set('app/currentRoute', currentPage);
}
}, [currentPage, isLoaded]);
const loadInitialData = async () => {
try {
const [savedRoute, savedVisiblePages] = await Promise.all([
storageUtil.get('app/currentRoute', 'timestamp'),
storageUtil.get('app/visiblePages', ['timestamp', 'storageCleaner'] as PageType[]),
]);
if (savedRoute) {
setCurrentPage(savedRoute);
}
if (savedVisiblePages) {
setVisiblePages(savedVisiblePages);
}
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
setIsLoaded(true);
}
};
const handlePageChange = (page: PageType) => {
setCurrentPage(page);
};
const NavButton = ({ pageKey }: { pageKey: PageType }) => {
const config = PAGE_CONFIG[pageKey];
if (!config) return null;
return (
<Box key={pageKey}>
<button
className={currentPage === pageKey ? 'nav-button active' : 'nav-button'}
onClick={() => handlePageChange(pageKey)}
>
{config.label}
</button>
</Box>
);
};
if (!isLoaded) {
return <div className="app">Loading...</div>;
}
return (
<div className="app">
<Box className="nav-container">
{(Object.keys(PAGE_CONFIG) as PageType[])
.filter((key) => visiblePages.includes(key))
.map((key) => <NavButton key={key} pageKey={key} />)}
</Box>
{currentPage === 'timestamp' && <TimestampPage />}
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
</div>
);
}
export default App;
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
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>,
);
@@ -0,0 +1,328 @@
import { useState, useEffect, useCallback } from 'react';
import {
Paper,
Typography,
Box,
Checkbox,
Button,
FormControlLabel,
Alert,
Snackbar,
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import WarningIcon from '@mui/icons-material/Warning';
import { storageUtil } from '@/utils/chromeStorage';
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
import {
getCurrentTab,
isRestrictedUrl,
clearStorage,
formatCleaningResult,
} from '@/utils/storageCleaner';
const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
};
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
autoRefresh: true,
selectedTypes: DEFAULT_OPTIONS,
};
export default function StorageCleanerPage() {
const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>('');
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
open: false,
message: '',
});
// Load tab info and user preferences
useEffect(() => {
const loadInfo = async () => {
const tab = await getCurrentTab();
if (!tab || !tab.url) {
setError('无法获取当前标签页');
return;
}
if (isRestrictedUrl(tab.url)) {
setError('存储清理功能不支持此页面');
return;
}
setDomain(new URL(tab.url).hostname);
// Load user preferences
const prefs = await storageUtil.get(
'storageCleaner/preferences',
DEFAULT_PREFERENCES,
);
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
};
loadInfo();
}, []);
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const handleClean = useCallback(async () => {
const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) {
setSnackbar({ open: true, message: '无法获取当前标签页' });
return;
}
setLoading(true);
try {
const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult);
// Save user preferences
await storageUtil.set('storageCleaner/preferences', {
autoRefresh,
selectedTypes: options,
});
// Auto refresh if enabled
if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id!);
}, 1500);
}
} catch (err) {
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
} finally {
setLoading(false);
setShowConfirm(false);
}
}, [options, autoRefresh]);
const handleRefresh = useCallback(async () => {
const tab = await getCurrentTab();
if (tab?.id !== undefined) {
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
setTimeout(() => {
chrome.tabs.reload(tab.id!);
}, 1500);
}
}, []);
if (error) {
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
<Alert severity="error" icon={<WarningIcon />}>
{error}
</Alert>
</Paper>
);
}
return (
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 2 }}>
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
: {domain || '加载中...'}
</Typography>
</Box>
{/* Storage Type Options */}
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Checkbox
checked={options.localStorage}
onChange={() => handleOptionChange('localStorage')}
/>
}
label="localStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.sessionStorage}
onChange={() => handleOptionChange('sessionStorage')}
/>
}
label="sessionStorage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.indexedDB}
onChange={() => handleOptionChange('indexedDB')}
/>
}
label="IndexedDB"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cookies}
onChange={() => handleOptionChange('cookies')}
/>
}
label="Cookies"
/>
<FormControlLabel
control={
<Checkbox
checked={options.cacheStorage}
onChange={() => handleOptionChange('cacheStorage')}
/>
}
label="Cache Storage"
/>
<FormControlLabel
control={
<Checkbox
checked={options.serviceWorkers}
onChange={() => handleOptionChange('serviceWorkers')}
/>
}
label="Service Workers"
/>
</Box>
</Box>
{/* Auto Refresh Option */}
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
/>
}
label="清理完成后自动刷新页面"
/>
</Box>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button
variant="contained"
onClick={() => setShowConfirm(true)}
disabled={loading}
fullWidth
>
{loading ? '清理中...' : '清理'}
</Button>
</Box>
{/* Result Display */}
{result && (
<Box sx={{ mb: 2 }}>
<Alert
severity={result.success ? 'success' : 'error'}
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
>
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
</Alert>
{!autoRefresh && result.success && (
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={handleRefresh}
fullWidth
>
</Button>
)}
</Box>
)}
{/* Confirmation Dialog */}
{showConfirm && (
<Paper
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255,255,255, 0.95)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
zIndex: 10,
}}
>
<Typography variant="h6"></Typography>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
</Typography>
<Box sx={{ mb: 1 }}>
{options.localStorage && (
<Typography variant="body2">- localStorage</Typography>
)}
{options.sessionStorage && (
<Typography variant="body2">- sessionStorage</Typography>
)}
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
{options.cacheStorage && (
<Typography variant="body2">- Cache Storage</Typography>
)}
{options.serviceWorkers && (
<Typography variant="body2">- Service Workers</Typography>
)}
</Box>
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', mb: 1 }}
>
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
</Button>
<Button variant="contained" color="error" onClick={handleClean}>
</Button>
</Box>
</Paper>
)}
{/* Snackbar */}
<Snackbar
open={snackbar.open}
autoHideDuration={3000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
>
<Alert severity="info" variant="filled">
{snackbar.message}
</Alert>
</Snackbar>
</Paper>
);
}
+412
View File
@@ -0,0 +1,412 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import dayjs from '@/utils/dayjs';
import {
Button,
TextField,
Select,
MenuItem,
Paper,
Stack,
Typography,
Box,
IconButton,
Snackbar,
Alert,
InputAdornment,
alpha,
Tooltip,
Theme,
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import CheckIcon from '@mui/icons-material/Check';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
// ================= 常量配置 =================
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
type UnitType = 'ms' | 's';
type ZoneType = (typeof ZONES)[number];
const INPUT_STYLE = {
'& .MuiOutlinedInput-root': {
bgcolor: 'grey.50',
borderRadius: 3,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { bgcolor: 'grey.100' },
'&.Mui-focused': {
bgcolor: '#fff',
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
},
'&.Mui-error': {
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
},
},
'& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
};
// ================= 子组件:实时时钟 =================
interface LiveClockProps {
unit: UnitType;
onCopy: (val: string) => void;
onUseNow: (val: number) => void;
}
const LiveClock = React.memo(({
unit,
onCopy,
onUseNow
}: LiveClockProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
const displayVal = useMemo(() =>
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
[now, unit]);
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
<Stack direction="row" spacing={1} alignItems="baseline">
<Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
{displayVal}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
{unit}
</Typography>
</Stack>
<Stack direction="row" spacing={0.5}>
<Tooltip title="填充到下方">
<IconButton
aria-label="use current time"
size="small"
onClick={() => onUseNow(now)}
sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }}
>
<AccessTimeIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="复制当前时间戳">
<IconButton
aria-label="copy current timestamp"
size="small"
onClick={() => onCopy(displayVal)}
sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
);
});
LiveClock.displayName = 'LiveClock';
// ================= 子组件:多维度结果展示 =================
interface ResultViewProps {
result: string;
mode: 'ts2dt' | 'dt2ts';
unit: UnitType;
zone: string;
onCopy: (val: string) => void;
}
const ResultView = React.memo(({
result,
mode,
unit,
zone,
onCopy
}: ResultViewProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
onCopy(result);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [onCopy, result]);
const extraInfo = useMemo(() => {
if (!result) return null;
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result)));
return {
relative: d.fromNow(),
iso: d.toISOString(),
utc: d.utc().format(DATE_FORMAT) + ' UTC',
};
}, [result, mode, zone, unit]);
if (!result) return null;
return (
<Box sx={{
mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
animation: 'fadeIn 0.3s ease-out',
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
}}>
<Typography variant="caption" sx={{ color: 'text.disabled', mb: 1, display: 'block', ml: 1, fontWeight: 500 }}>
</Typography>
<TextField
fullWidth
value={result}
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="copy result"
size="small"
onClick={handleCopy}
sx={{
color: copied ? 'success.main' : 'primary.main',
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
transform: copied ? 'scale(1.2)' : 'scale(1)',
}}
>
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
sx={{
...INPUT_STYLE,
mb: 2,
'& .MuiOutlinedInput-root': {
...INPUT_STYLE['& .MuiOutlinedInput-root'],
bgcolor: alpha('#2563eb', 0.03),
},
}}
/>
{/* 辅助信息预览 */}
<Stack spacing={1} sx={{ px: 1 }}>
{[
{ label: '相对时间', value: extraInfo?.relative },
{ label: 'ISO 8601', value: extraInfo?.iso },
{ label: 'UTC 时间', value: extraInfo?.utc },
].map((item) => (
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
<Typography
variant="caption"
onClick={() => { if (item.value) onCopy(item.value); }}
sx={{
fontFamily: 'monospace',
color: 'text.primary',
cursor: 'pointer',
'&:hover': { color: 'primary.main', textDecoration: 'underline' }
}}
>
{item.value}
</Typography>
</Box>
))}
</Stack>
</Box>
);
});
ResultView.displayName = 'ResultView';
// ================= 主页面组件 =================
export default function TimestampPage() {
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
const [tsInput, setTsInput] = useState(() => String(Date.now()));
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
const [unit, setUnit] = useState<UnitType>('ms');
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
const [result, setResult] = useState('');
const [error, setError] = useState('');
const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setSnack({ open: true, msg: '已复制' });
} catch {
setSnack({ open: true, msg: '复制失败' });
}
}, []);
const convert = useCallback(() => {
if (mode === 'ts2dt') {
const rawInput = tsInput.trim();
if (!rawInput) return;
const num = Number(rawInput);
if (isNaN(num)) { setError('无效数字'); return; }
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
if (!d.isValid()) { setError('无效时间戳'); return; }
setError('');
setResult(d.tz(zone).format(DATE_FORMAT));
} else {
const rawInput = dtInput.trim();
if (!rawInput) return;
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
if (!d.isValid()) { setError('格式错误'); return; }
setError('');
const ms = d.valueOf();
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
}
}, [mode, tsInput, dtInput, unit, zone]);
// 智能实时转换 (Debounce Effect)
useEffect(() => {
const timer = setTimeout(() => {
convert();
}, 400);
return () => {
clearTimeout(timer);
};
}, [convert]);
const handleUseNow = useCallback((now: number) => {
if (mode === 'ts2dt') {
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
} else {
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
}
}, [mode, unit, zone]);
return (
<Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
<Paper
elevation={0}
sx={{
p: 2.5,
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
}}
>
{/* 1. 实时时钟 */}
<Box sx={{ position: 'relative' }}>
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
<Tooltip title="切换单位">
<IconButton
aria-label="switch unit"
size="small"
onClick={() => { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }}
sx={{
position: 'absolute', right: 80, top: 4, color: 'grey.400',
transition: 'transform 0.3s ease',
'&:hover': { transform: 'rotate(180deg)', color: 'primary.main' }
}}
>
<SwapHorizIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{/* 2. 模式切换 */}
<Box sx={{ position: 'relative', display: 'flex', p: 0.5, bgcolor: 'grey.100', borderRadius: 3.5, mb: 3, overflow: 'hidden' }}>
<Box
sx={{
position: 'absolute', height: 'calc(100% - 8px)', width: 'calc(50% - 4px)',
bgcolor: '#fff', borderRadius: 3, boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
top: 4, left: 4,
}}
/>
{(['ts2dt', 'dt2ts'] as const).map((m) => (
<Button
key={m} fullWidth disableRipple
onClick={() => { setMode(m); setError(''); setResult(''); }}
sx={{
position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
color: mode === m ? 'text.primary' : 'text.disabled',
'&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
}}
>
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
</Button>
))}
</Box>
{/* 3. 输入与设置 */}
<Stack spacing={2} sx={{ mb: 3 }}>
<TextField
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
value={mode === 'ts2dt' ? tsInput : dtInput}
onChange={(e) => {
const val = e.target.value;
if (mode === 'ts2dt') {
setTsInput(val);
} else {
setDtInput(val);
}
setError('');
}}
error={!!error}
helperText={error}
fullWidth
sx={INPUT_STYLE}
/>
<Stack direction="row" spacing={2}>
<Select
fullWidth value={unit}
onChange={(e) => { setUnit(e.target.value as UnitType); }}
sx={{ ...INPUT_STYLE, flex: 1 }}
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
>
<MenuItem value="ms"> (ms)</MenuItem>
<MenuItem value="s"> (s)</MenuItem>
</Select>
<Select
fullWidth value={zone}
onChange={(e) => { setZone(e.target.value as ZoneType); }}
sx={{ ...INPUT_STYLE, flex: 1.5 }}
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
>
{ZONES.map((z) => (
<MenuItem key={z} value={z}>{z}</MenuItem>
))}
</Select>
</Stack>
</Stack>
{/* 4. 转换操作 (作为手动确认) */}
<Button
fullWidth variant="contained" disableElevation disableRipple
onClick={convert}
sx={{
py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
bgcolor: 'primary.main', transition: 'all 0.2s',
'&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
'&:active': { transform: 'translateY(0)' }
}}
>
</Button>
{/* 5. 结果展示 */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
</Paper>
<Snackbar
open={snack.open} autoHideDuration={1500}
onClose={() => { setSnack((s) => ({ ...s, open: false })); }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity="success" variant="filled" icon={false} sx={{ borderRadius: 2.5, bgcolor: 'grey.900' }}>
{snack.msg}
</Alert>
</Snackbar>
</Box>
);
}
+45
View File
@@ -0,0 +1,45 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import * as reactHooks from 'eslint-plugin-react-hooks';
import * as reactPlugin from 'eslint-plugin-react';
import globals from 'globals';
export default tseslint.config(
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] },
{
files: [
'hooks/**/*.{ts,tsx}',
'entrypoints/**/*.{ts,tsx}',
'pages/**/*.{ts,tsx}',
'utils/**/*.{ts,tsx}',
'components/**/*.{ts,tsx}',
'services/**/*.{ts,tsx}',
],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.browser,
...globals.node,
},
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了
'react-hooks': reactHooks as any,
},
rules: {
...reactHooks.configs.recommended.rules,
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'react/react-in-jsx-scope': 'off',
},
},
);
+15
View File
@@ -0,0 +1,15 @@
export default {
// 对于代码文件:
'*.{ts,tsx,js,jsx}': [
// 1. ESLint: 依然检查具体文件,拦截未使用变量
'eslint --fix --max-warnings=0 --no-warn-ignored',
// 2. TypeScript: 使用函数形式
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好”
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
() => 'tsc --noEmit --skipLibCheck',
],
// 对于其他文件:
'*.{json,css,scss,md}': ['prettier --write'],
};
+5639 -13382
View File
File diff suppressed because it is too large Load Diff
+43 -38
View File
@@ -1,45 +1,50 @@
{
"name": "chrome-extension-router-demo",
"version": "0.1.0",
"name": "wxt-react-starter",
"description": "manifest.json description",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0",
"date-fns": "^4.1.0",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^6.0.3",
"react-router-dom": "^6.30.2",
"react-scripts": "5.0.1",
"remark-gfm": "^1.0.0",
"web-vitals": "^2.1.4"
},
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare",
"prepare": "husky",
"lint": "eslint . --max-warnings=0"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.8",
"@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19",
"react": "^19.2.3",
"react-dom": "^19.2.3"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"devDependencies": {
"@types/chrome": "^0.1.36",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/webextension-polyfill": "^0.12.4",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitejs/plugin-react": "^4.3.4",
"@wxt-dev/module-react": "^1.1.5",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.2.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"prettier": "^3.8.1",
"terser": "^5.46.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"wxt": "^0.20.6"
}
}
-25
View File
@@ -1,25 +0,0 @@
console.log('Background script loaded');
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log('收到消息:', request);
if (request.action === 'copy') {
console.log('开始复制文本:', request.text);
navigator.clipboard.writeText(request.text)
.then(() => {
console.log('复制成功');
sendResponse({success: true});
})
.catch((err) => {
console.error('复制失败:', err);
sendResponse({success: false, error: err.message});
});
return true; // 保持消息端口开放以支持异步响应
}
// 对于未知的操作,也返回响应
sendResponse({success: false, error: '未知操作'});
return false;
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

-25
View File
@@ -1,25 +0,0 @@
{
"manifest_version": 3,
"name": "React Router Chrome Extension",
"version": "1.0",
"description": "A Chrome Extension with React Router: User List + Actions Pages",
"permissions": [
"storage",
"clipboardWrite"
],
"background": {
"service_worker": "background.js"
},
"icons": {
"16": "favicon.ico",
"48": "favicon.ico",
"128": "favicon.ico"
},
"action": {
"default_popup": "index.html"
},
"options_ui": {
"page": "index.html",
"open_in_tab": true
}
}
-3
View File
@@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+15
View File
@@ -0,0 +1,15 @@
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_305_516)">
<g clip-path="url(#clip1_305_516)">
<path d="M49.0229 69.1875C54.1272 69.1875 58.265 65.0497 58.265 59.9454V50.7033H59.9454C65.0497 50.7033 69.1875 46.5655 69.1875 41.4612C69.1875 36.357 65.0497 32.2191 59.9454 32.2191H58.265V22.9771C58.265 17.8728 54.1272 13.735 49.0229 13.735H39.7809V12.0546C39.7809 6.95032 35.643 2.8125 30.5388 2.8125C25.4345 2.8125 21.2967 6.95032 21.2967 12.0546V13.735H12.0546C6.95032 13.735 2.8125 17.8728 2.8125 22.9771V32.2191H4.49288C9.59714 32.2191 13.735 36.357 13.735 41.4612C13.735 46.5655 9.59714 50.7033 4.49288 50.7033H2.8125V69.1875H21.2967V67.5071C21.2967 62.4029 25.4345 58.265 30.5388 58.265C35.643 58.265 39.7809 62.4029 39.7809 67.5071V69.1875H49.0229Z" stroke="#67D55E" stroke-width="5.625"/>
</g>
</g>
<defs>
<clipPath id="clip0_305_516">
<rect width="72" height="72" fill="white"/>
</clipPath>
<clipPath id="clip1_305_516">
<rect width="72" height="72" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

-1021
View File
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
import {HashRouter as Router, Routes, Route, NavLink} from 'react-router-dom';
import {useState, useEffect} from 'react';
import TimestampPage from './pages/TimestampPage';
// import ElectronicWoodenFishPage from "./pages/ElectronicWoodenFishPage";
import './App.css';
// 导航项配置数组,便于后续添加
// 为了测试折叠功能,我们添加更多导航项
const navItems = [
{path: '/', label: '时间戳', element: <TimestampPage/>},
// {path: '/dzmy', label: '电子木鱼', element: <ElectronicWoodenFishPage/>},
];
function App() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [visibleItems, setVisibleItems] = useState(navItems.length);
// 检测屏幕尺寸变化
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
setIsMobile(width < 768);
// 根据屏幕宽度决定显示多少个导航项
if (width >= 768) {
setVisibleItems(navItems.length); // 大屏幕显示所有
} else if (width >= 480) {
// 中等屏幕:如果导航项超过3个,显示3个,否则显示全部
setVisibleItems(Math.min(3, navItems.length));
} else {
// 小屏幕:如果导航项超过2个,显示2个,否则显示全部
setVisibleItems(Math.min(2, navItems.length));
}
};
handleResize(); // 初始调用
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 计算哪些导航项应该显示,哪些应该折叠
const visibleNavItems = navItems.slice(0, visibleItems);
const collapsedNavItems = navItems.slice(visibleItems);
return (<Router>
<div className="app">
<nav className="nav">
<div className="nav-content">
<ul className={`nav-list ${isMenuOpen ? 'open' : ''}`}>
{visibleNavItems.map((item) => (
<li key={item.path}>
<NavLink
to={item.path}
className={({isActive}) => isActive ? "nav-link active" : "nav-link"}
onClick={() => isMobile && setIsMenuOpen(false)}
>
{item.label}
</NavLink>
</li>
))}
{/* 折叠的导航项 */}
{collapsedNavItems.length > 0 && (
<li className="nav-collapse-item">
<div className={`nav-collapse-content ${isMenuOpen ? 'show' : ''}`}>
{isMenuOpen && collapsedNavItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({isActive}) => isActive ? "nav-link active" : "nav-link"}
onClick={() => setIsMenuOpen(false)}
>
{item.label}
</NavLink>
))}
</div>
<button
className="nav-toggle"
onClick={() => setIsMenuOpen(!isMenuOpen)}
aria-label={isMenuOpen ? "收起菜单" : "展开菜单"}
>
<span className="nav-toggle-icon">{isMenuOpen ? '×' : '☰'}</span>
{collapsedNavItems.length > 0 && !isMenuOpen && (
<span className="nav-collapse-count">+{collapsedNavItems.length}</span>
)}
</button>
</li>
)}
</ul>
</div>
</nav>
<Routes>
{navItems.map((item) => (
<Route key={item.path} path={item.path} element={item.element}/>
))}
</Routes>
</div>
</Router>);
}
export default App;
-8
View File
@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-110
View File
@@ -1,110 +0,0 @@
import {useState, useCallback} from "react";
const CopyButton = ({
text = '要复制的文本',
buttonText = '复制文本',
className = 'action-btn',
}) => {
const [btnText, setBtnText] = useState(buttonText);
const [copyStatus, setCopyStatus] = useState('');
// 重置按钮状态的函数
const resetButton = useCallback(() => {
setBtnText(buttonText);
setCopyStatus('');
}, [buttonText]);
// 复制成功的处理
const handleCopySuccess = useCallback(() => {
setCopyStatus('success');
setBtnText('复制成功!');
// 2秒后恢复
setTimeout(() => {
resetButton();
}, 2000);
}, [text, resetButton]);
// 复制失败的处理
const handleCopyError = useCallback(() => {
setCopyStatus('error');
// 2秒后恢复
setTimeout(() => {
resetButton();
}, 2000);
}, [resetButton]);
const handleCopy = async () => {
try {
console.log('开始复制:', text);
// 首先尝试直接使用navigator.clipboard(在popup页面中可能可用)
try {
console.log('尝试直接使用navigator.clipboard复制');
await navigator.clipboard.writeText(text.toString());
console.log('直接复制成功');
handleCopySuccess();
return;
} catch (directError) {
console.log('直接复制失败,尝试使用Chrome扩展API:', directError);
}
// 如果直接复制失败,尝试使用Chrome扩展API
if (chrome && chrome.runtime && chrome.runtime.sendMessage) {
console.log('使用Chrome扩展API复制');
// 使用Chrome扩展API复制
chrome.runtime.sendMessage({
action: 'copy',
text: text
}, (response) => {
console.log('收到background响应:', response, 'lastError:', chrome.runtime.lastError);
// 检查是否有运行时错误
if (chrome.runtime.lastError) {
console.error('Chrome运行时错误:', chrome.runtime.lastError.message);
handleCopyError();
return;
}
// 检查响应
if (response && response.success) {
console.log('通过background复制成功');
handleCopySuccess();
} else {
console.error('通过background复制失败:', response?.error);
handleCopyError();
}
});
} else {
console.error('没有可用的复制方法');
handleCopyError();
}
} catch (err) {
console.error('复制过程中发生错误:', err);
handleCopyError();
}
};
return (
<button
onClick={handleCopy}
className={className}
style={{
backgroundColor: copyStatus === 'success' ? '#4CAF50' :
copyStatus === 'error' ? '#f44336' : '',
color: copyStatus ? 'white' : '',
transition: 'all 0.3s ease',
padding: '8px 16px',
border: 'none',
borderRadius: '8px',
cursor: 'pointer'
}}
>
{btnText}
</button>
);
};
export default CopyButton;
-198
View File
@@ -1,198 +0,0 @@
import {formatWithDate, formatWithZone, TimezoneOptions} from "../utils/timeUtils";
import {useState, useCallback} from "react";
/**
* 日期时间转时间戳组件
*
* 功能特性:
* 1. 将日期时间字符串转换为时间戳
* 2. 支持多种时区选择
* 3. 支持毫秒和秒单位切换
* 4. 提供输入验证和错误提示
* 5. 实时单位转换
*
* @component
* @example
* ```jsx
* <DatetimeToTimestamp />
* ```
*
* @returns {JSX.Element} 日期时间转时间戳组件
*/
// 常用时区列表
const TIME_ZONE_LIST = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'America/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Singapore',
'Asia/Dubai',
'Asia/Kolkata',
'Australia/Sydney',
'Pacific/Auckland',
];
// 时间戳单位选项
const TIMESTAMP_UNITS = [
{value: 'milliseconds', label: '毫秒(ms)'},
{value: 'seconds', label: '秒(s)'},
];
export function DatetimeToTimestamp() {
/** @type {[string, function]} 输入的日期时间字符串 */
const [dateValue, setDateValue] = useState(() => formatWithZone(Date.now()));
/** @type {[string, function]} 选择的时区 */
const [selectedZone, setSelectedZone] = useState('Asia/Shanghai');
/** @type {[string, function]} 转换结果 */
const [result, setResult] = useState('');
/** @type {[string, function]} 时间戳单位 ('milliseconds' | 'seconds') */
const [unit, setUnit] = useState('milliseconds');
/** @type {[string, function]} 错误信息 */
const [error, setError] = useState('');
/**
* 转换日期时间为时间戳
* @type {function(): void}
*/
const handleConvertDatetimeToTimestamp = useCallback(() => {
try {
setError('');
const timestamp = formatWithDate(dateValue, selectedZone);
if (isNaN(timestamp)) {
setError('无效的日期时间格式');
setResult('');
return;
}
const finalResult = unit === 'milliseconds'
? timestamp
: Math.floor(timestamp / 1000);
setResult(finalResult.toString());
} catch (err) {
setError('转换失败,请检查输入格式');
setResult('');
}
}, [dateValue, selectedZone, unit]);
/**
* 处理日期时间输入变化
* @type {function(React.ChangeEvent<HTMLInputElement>): void}
*/
const handleDateChange = useCallback((e) => {
setDateValue(e.target.value);
setError(''); // 清除错误信息
}, []);
/**
* 处理时区选择变化
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
*/
const handleZoneChange = useCallback((e) => {
setSelectedZone(e.target.value);
}, []);
/**
* 处理时间戳单位变化
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
*/
const handleUnitChange = useCallback((e) => {
const newUnit = e.target.value;
setUnit(newUnit);
// 如果已有结果,重新计算
if (result) {
const currentResult = parseInt(result, 10);
if (!isNaN(currentResult)) {
const newResult = newUnit === 'milliseconds'
? currentResult * 1000
: Math.floor(currentResult / 1000);
setResult(newResult.toString());
}
}
}, [result]);
return (
<div className="datetime-converter">
<h2 className="converter-title">日期时间转时间戳</h2>
<div className="converter-form">
<div className="input-group">
<input
type="text"
placeholder="输入日期时间 (如: 2024-01-01 12:00:00)"
value={dateValue}
className="datetime-input"
onChange={handleDateChange}
aria-label="输入要转换的日期时间"
title="支持格式: YYYY-MM-DD HH:mm:ss"
/>
<select
value={selectedZone}
className="timezone-select"
onChange={handleZoneChange}
aria-label="选择时区"
>
<TimezoneOptions zones={TIME_ZONE_LIST}/>
</select>
</div>
{error && (
<div className="error-message" role="alert">
{error}
</div>
)}
<div className="action-group">
<button
className="converter-btn action-btn"
onClick={handleConvertDatetimeToTimestamp}
aria-label="转换日期时间为时间戳"
>
转换
</button>
</div>
<div className="result-group">
<input
type="text"
placeholder="转换结果"
value={result}
className="result-input"
readOnly
aria-label="转换结果"
/>
<select
value={unit}
className="unit-select"
onChange={handleUnitChange}
aria-label="选择时间戳单位"
>
{TIMESTAMP_UNITS.map(({value, label}) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
</div>
);
}
-147
View File
@@ -1,147 +0,0 @@
import {useEffect, useState, useRef, useCallback} from "react";
import CopyButton from "./CopyButton";
/**
* 时间戳显示和执行组件
*
* 功能特性:
* 1. 实时显示当前时间戳(毫秒/秒)
* 2. 支持毫秒和秒单位切换
* 3. 支持启动/停止时间戳自动更新
* 4. 提供复制时间戳功能
* 5. 响应式设计和良好的可访问性
*
* @component
* @example
* ```jsx
* <TimestampExecution />
* ```
*
* @returns {JSX.Element} 时间戳组件
*/
export function TimestampExecution() {
/** @type {[number, function]} 当前时间戳(毫秒)和更新函数 */
const [currentTimestamp, setCurrentTimestamp] = useState(() => Math.floor(Date.now()));
/** @type {[boolean, function]} 是否显示毫秒(true=毫秒,false=秒) */
const [showMilliseconds, setShowMilliseconds] = useState(true);
/** @type {[boolean, function]} 时间戳是否正在自动更新 */
const [isRunningTimestamp, setIsRunningTimestamp] = useState(true);
/** @type {React.RefObject<NodeJS.Timeout | null>} 定时器引用,用于清理 */
const timerRef = useRef(null);
/**
* 计算显示的时间戳值
* @type {number}
*/
const displayTimestamp = showMilliseconds
? currentTimestamp
: Math.floor(currentTimestamp / 1000);
/**
* 计算单位文本
* @type {string}
*/
const unitText = showMilliseconds ? '毫秒' : '秒';
/**
* 定时更新时间戳的副作用
* 根据 isRunningTimestamp 和 showMilliseconds 控制定时器的启停和间隔
*/
useEffect(() => {
// 清除之前的定时器
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// 如果需要运行,创建新的定时器
if (isRunningTimestamp) {
const interval = showMilliseconds ? 100 : 1000;
timerRef.current = setInterval(() => {
setCurrentTimestamp(Math.floor(Date.now()));
}, interval);
}
// 清理函数
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [isRunningTimestamp, showMilliseconds]);
/**
* 切换时间戳显示单位(毫秒/秒)
* @type {function(): void}
*/
const toggleUnit = useCallback(() => {
setShowMilliseconds(prev => !prev);
}, []);
/**
* 切换时间戳自动更新状态(启动/停止)
* @type {function(): void}
*/
const toggleTimestamp = useCallback(() => {
setIsRunningTimestamp(prev => !prev);
}, []);
/**
* 切换单位按钮的辅助文本
* @type {string}
*/
const unitButtonLabel = showMilliseconds ? '切换为秒显示' : '切换为毫秒显示';
/**
* 启动/停止按钮的辅助文本
* @type {string}
*/
const toggleButtonLabel = isRunningTimestamp ? '停止时间戳自动更新' : '开始时间戳自动更新';
/**
* 启动/停止按钮的显示文本
* @type {string}
*/
const toggleButtonText = isRunningTimestamp ? '停止' : '开始';
return (
<div className="timestamp-container">
<div className="timestamp-display">
<span className="timestamp-value">{displayTimestamp}</span>
<span className="timestamp-unit">{unitText}</span>
</div>
<div className="timestamp-controls">
<button
type="button"
className="timestamp-btn action-btn"
onClick={toggleUnit}
aria-label={unitButtonLabel}
title={unitButtonLabel}
>
切换单位
</button>
<CopyButton
text={currentTimestamp.toString()}
buttonText="复制时间戳"
aria-label="复制当前时间戳到剪贴板"
/>
<button
type="button"
className={`timestamp-btn ${isRunningTimestamp ? 'stop-btn' : 'action-btn'}`}
onClick={toggleTimestamp}
aria-label={toggleButtonLabel}
title={toggleButtonLabel}
>
{toggleButtonText}
</button>
</div>
</div>
);
}
-186
View File
@@ -1,186 +0,0 @@
import {useState, useCallback} from "react";
import {formatWithZone, TimezoneOptions} from "../utils/timeUtils";
/**
* 时间戳转日期时间组件
*
* 功能特性:
* 1. 将时间戳转换为日期时间字符串
* 2. 支持多种时区选择
* 3. 支持毫秒和秒单位切换
* 4. 提供输入验证和错误提示
*
* @component
* @example
* ```jsx
* <TimestampToDatetime />
* ```
*
* @returns {JSX.Element} 时间戳转日期时间组件
*/
// 常用时区列表
const TIME_ZONE_LIST = [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'America/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Singapore',
'Asia/Dubai',
'Asia/Kolkata',
'Australia/Sydney',
'Pacific/Auckland',
];
// 时间戳单位选项
const TIMESTAMP_UNITS = [
{value: 'milliseconds', label: '毫秒(ms)'},
{value: 'seconds', label: '秒(s)'},
];
export function TimestampToDatetime() {
/** @type {[string, function]} 输入的时间戳值 */
const [timestampValue, setTimestampValue] = useState(Date.now());
/** @type {[string, function]} 转换结果 */
const [timestampResult, setTimestampResult] = useState('');
/** @type {[string, function]} 时间戳单位 ('milliseconds' | 'seconds') */
const [unit, setUnit] = useState('milliseconds');
/** @type {[string, function]} 选择的时区 */
const [selectedZone, setSelectedZone] = useState('Asia/Shanghai');
/** @type {[string, function]} 错误信息 */
const [error, setError] = useState('');
/**
* 转换时间戳为日期时间
* @type {function(): void}
*/
const handleConvertTimestampToDate = useCallback(() => {
try {
setError('');
if (!timestampValue) {
setError('请输入时间戳');
setTimestampResult('');
return;
}
const numericValue = Number(timestampValue);
if (isNaN(numericValue)) {
setError('无效的时间戳格式');
setTimestampResult('');
return;
}
const result = formatWithZone(numericValue, selectedZone, unit);
setTimestampResult(result);
} catch (err) {
setError('转换失败,请检查输入格式');
setTimestampResult('');
}
}, [timestampValue, selectedZone, unit]);
/**
* 处理时间戳输入变化
* @type {function(React.ChangeEvent<HTMLInputElement>): void}
*/
const handleInputChange = useCallback((e) => {
setTimestampValue(e.target.value);
setError(''); // 清除错误信息
}, []);
/**
* 处理时区选择变化
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
*/
const handleZoneChange = useCallback((e) => {
setSelectedZone(e.target.value);
}, []);
/**
* 处理时间戳单位变化
* @type {function(React.ChangeEvent<HTMLSelectElement>): void}
*/
const handleUnitChange = useCallback((e) => {
setUnit(e.target.value);
}, []);
return (
<div className="datetime-converter">
<h2 className="converter-title">时间戳转日期时间</h2>
<div className="converter-form">
<div className="input-group">
<input
type="number"
placeholder="输入时间戳 (如: 1704067200000)"
value={timestampValue}
className="datetime-input"
onChange={handleInputChange}
aria-label="输入要转换的时间戳"
title="支持毫秒或秒为单位的时间戳"
/>
<select
value={unit}
className="unit-select"
onChange={handleUnitChange}
aria-label="选择时间戳单位"
>
{TIMESTAMP_UNITS.map(({value, label}) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{error && (
<div className="error-message" role="alert">
{error}
</div>
)}
<div className="action-group">
<button
className="converter-btn action-btn"
onClick={handleConvertTimestampToDate}
aria-label="转换时间戳为日期时间"
>
转换
</button>
</div>
<div className="result-group">
<input
type="text"
placeholder="转换结果"
value={timestampResult}
className="result-input"
readOnly
aria-label="转换结果"
/>
<select
value={selectedZone}
className="timezone-select"
onChange={handleZoneChange}
aria-label="选择时区"
>
<TimezoneOptions zones={TIME_ZONE_LIST}/>
</select>
</div>
</div>
</div>
)
}
-18
View File
@@ -1,18 +0,0 @@
const TodoList = ({todoContentList = []}) => {
const todoLi = todoContentList?.map((todo, index) => {
return (
<li key={index}>{todo}</li>
)
})
return (
<div className="todo-list">
<ul>
{todoLi}
</ul>
</div>
);
};
export default TodoList;
-309
View File
@@ -1,309 +0,0 @@
/* Markdown Editor Styles */
.markdown-editor {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
.toolbar {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-label {
font-weight: 600;
color: #495057;
font-size: 0.95rem;
white-space: nowrap;
}
.format-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.format-button {
padding: 6px 12px;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
color: #495057;
cursor: pointer;
transition: all 0.2s ease;
min-width: 36px;
text-align: center;
}
.format-button:hover {
background-color: #e9ecef;
border-color: #adb5bd;
transform: translateY(-1px);
}
.format-button:active {
transform: translateY(0);
}
.action-button {
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.clear-button {
background-color: #dc3545;
color: white;
}
.clear-button:hover {
background-color: #c82333;
}
.reset-button {
background-color: #6c757d;
color: white;
}
.reset-button:hover {
background-color: #5a6268;
}
@media (max-width: 768px) {
.toolbar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.toolbar-section {
flex-direction: column;
align-items: stretch;
}
.format-buttons {
justify-content: center;
}
.toolbar-label {
text-align: center;
margin-bottom: 5px;
}
}
.editor-container {
margin-bottom: 20px;
}
@media (max-width: 768px) {
.editor-container {
grid-template-columns: 1fr;
gap: 20px;
}
}
.input-section,
.preview-section {
display: flex;
flex-direction: column;
}
.input-section label,
.preview-section label {
margin-bottom: 10px;
font-weight: 600;
color: #444;
font-size: 1.1rem;
}
.markdown-input {
box-sizing: border-box;
width: 100%;
min-height: 400px;
padding: 15px;
border: 2px solid #ddd;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
transition: border-color 0.2s ease;
background-color: #f9f9f9;
}
.markdown-input:focus {
outline: none;
border-color: #4a90e2;
background-color: #fff;
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1);
}
.markdown-input::placeholder {
color: #999;
}
.markdown-preview {
box-sizing: border-box;
width: 100%;
min-height: 400px;
max-height: 600px;
padding: 15px;
border: 2px solid #ddd;
border-radius: 8px;
overflow-y: auto;
background-color: #fff;
line-height: 1.6;
}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}
.markdown-preview h1 {
font-size: 2em;
border-bottom: 2px solid #eaeaea;
padding-bottom: 0.3em;
}
.markdown-preview h2 {
font-size: 1.5em;
border-bottom: 1px solid #eaeaea;
padding-bottom: 0.3em;
}
.markdown-preview p {
margin: 0 0 1em 0;
}
.markdown-preview ul,
.markdown-preview ol {
padding-left: 2em;
margin: 0 0 1em 0;
}
.markdown-preview li {
margin-bottom: 0.5em;
}
.markdown-preview code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 8px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.markdown-preview pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 8px;
margin: 0 0 1em 0;
}
.markdown-preview pre code {
padding: 0;
background-color: transparent;
border-radius: 8px;
}
.markdown-preview blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 0 0 1em 0;
}
.markdown-preview table {
border-collapse: collapse;
margin: 0 0 1em 0;
width: 100%;
}
.markdown-preview table th,
.markdown-preview table td {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.markdown-preview table th {
font-weight: 600;
background-color: #f6f8fa;
}
.markdown-preview table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.markdown-preview a {
color: #0366d6;
text-decoration: none;
}
.markdown-preview a:hover {
text-decoration: underline;
}
.markdown-preview img {
max-width: 100%;
height: auto;
}
.markdown-preview hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
/* Scrollbar styling */
.markdown-preview::-webkit-scrollbar {
width: 8px;
}
.markdown-preview::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 8px;
}
.markdown-preview::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 8px;
}
.markdown-preview::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
-51
View File
@@ -1,51 +0,0 @@
import './TodoMarkdownEditor.css';
import ReactMarkdown from "react-markdown";
import {useState, useRef} from "react";
const TodoMarkdownEditor = () => {
const [markdown, setMarkdown] = useState('# Hello, world!\n\nThis is a simple paragraph with some **bold** text.');
const textareaRef = useRef(null);
const handleClear = () => {
setMarkdown('');
};
const handleReset = () => {
setMarkdown('# Hello, world!\n\nThis is a simple paragraph with some **bold** text.');
};
return (<div className="markdown-editor">
<div className="editor-container">
<div className="preview-section">
<label>预览区</label>
<div className="markdown-preview">
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
</div>
<div className="input-section">
<label htmlFor="markdown-input">编辑区</label>
<textarea
id="markdown-input"
ref={textareaRef}
placeholder="输入Markdown内容..."
className="markdown-input"
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
/>
</div>
<div className="toolbar">
<div className="toolbar-section">
<button className="action-button clear-button" onClick={handleClear}>
清空
</button>
<button className="action-button reset-button" onClick={handleReset}>
重置
</button>
</div>
</div>
</div>
</div>);
};
export default TodoMarkdownEditor;
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

-445
View File
@@ -1,445 +0,0 @@
/* CSS 变量定义 */
:root {
--dzmy-bg-color: #0a0a0a;
--dzmy-bg-gradient: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
--dzmy-text-color: #ffffff;
--dzmy-accent-color: #ffd700;
--dzmy-accent-glow: 0 0 20px rgba(255, 215, 0, 0.3);
--dzmy-container-height: 500px;
--dzmy-img-width: 160px;
--dzmy-img-height: 120px;
--dzmy-animation-duration: 0.12s;
--dzmy-float-duration: 0.5s;
--dzmy-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
--dzmy-border-radius: 8px;
--dzmy-controls-bg: rgba(255, 255, 255, 0.05);
--dzmy-controls-border: 1px solid rgba(255, 255, 255, 0.1);
}
/* 主容器 */
.dzmy-bg {
background: var(--dzmy-bg-gradient);
min-height: var(--dzmy-container-height);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
border-radius: var(--dzmy-border-radius);
box-shadow: var(--dzmy-shadow);
border: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
padding: 20px;
margin-top: 20px;
}
/* 添加装饰性背景元素 */
.dzmy-bg::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 215, 0, 0.3), transparent);
}
.dzmy-bg::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 215, 0, 0.3), transparent);
}
/* 计数显示 */
.dzmy-count {
position: absolute;
top: 20px;
left: 20px;
color: var(--dzmy-accent-color);
font-size: 20px;
font-weight: 700;
z-index: 10;
background: rgba(0, 0, 0, 0.3);
padding: 8px 16px;
border-radius: var(--dzmy-border-radius);
border: 1px solid rgba(255, 215, 0, 0.2);
box-shadow: var(--dzmy-accent-glow);
backdrop-filter: blur(4px);
transition: all 0.3s ease;
}
.dzmy-count:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px rgba(255, 215, 0, 0.4);
}
/* 木鱼图像 */
.dzmy-img {
width: var(--dzmy-img-width);
height: var(--dzmy-img-height);
max-width: 100%;
max-height: 100%;
cursor: pointer;
transition: all var(--dzmy-animation-duration) ease;
transform: scale(1);
will-change: transform;
touch-action: manipulation;
user-select: none;
object-fit: contain;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5));
position: relative;
z-index: 5;
}
.dzmy-img:hover {
filter: drop-shadow(0 6px 20px rgba(255, 215, 0, 0.3));
}
/* 木鱼点击激活状态 */
.dzmy-img.active {
transform: scale(0.92);
filter: drop-shadow(0 2px 8px rgba(255, 215, 0, 0.5));
}
/* 添加点击涟漪效果 */
.dzmy-img::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: var(--dzmy-border-radius);
background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 70%);
transform: translate(-50%, -50%);
opacity: 0;
transition: all 0.3s ease;
}
.dzmy-img.active::after {
width: 200px;
height: 200px;
opacity: 1;
animation: ripple 0.3s ease-out;
}
@keyframes ripple {
0% {
width: 0;
height: 0;
opacity: 0.5;
}
100% {
width: 200px;
height: 200px;
opacity: 0;
}
}
/* 木鱼和提示容器 */
.dzmy-box {
text-align: center;
position: relative;
z-index: 5;
}
/* 浮动提示容器 */
.tips {
position: absolute;
top: -50px;
width: 100%;
text-align: center;
pointer-events: none;
z-index: 20;
overflow: visible;
}
/* 单个浮动提示项 */
.tip-item {
position: absolute;
width: 100%;
animation: floatUp var(--dzmy-float-duration) ease-out forwards;
color: var(--dzmy-accent-color);
font-weight: 800;
will-change: transform, opacity;
transform: translateZ(0);
font-size: 28px;
text-shadow:
0 0 10px rgba(255, 215, 0, 0.8),
0 0 20px rgba(255, 215, 0, 0.4),
0 0 30px rgba(255, 215, 0, 0.2);
letter-spacing: 1px;
pointer-events: none;
opacity: 0;
animation-delay: 0.1s;
}
/* 浮动动画 */
@keyframes floatUp {
0% {
transform: translate3d(0, 0, 0) scale(0.8);
opacity: 0;
}
20% {
transform: translate3d(0, -10px, 0) scale(1.1);
opacity: 1;
}
100% {
transform: translate3d(0, -80px, 0) scale(0.9);
opacity: 0;
}
}
/* 控制区域 */
.dzmy-controls {
margin-bottom: 20px;
display: flex;
gap: 16px;
justify-content: center;
align-items: center;
padding: 16px 24px;
background: var(--dzmy-controls-bg);
border-radius: var(--dzmy-border-radius);
margin-top: 20px;
border: var(--dzmy-controls-border);
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
}
.dzmy-controls:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 215, 0, 0.2);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
}
/* 控制区域内的输入框 */
.dzmy-controls input {
width: auto;
min-width: 140px;
margin-bottom: 0;
flex: 1;
max-width: 240px;
padding: 12px 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--dzmy-border-radius);
background: rgba(0, 0, 0, 0.3);
color: var(--dzmy-text-color);
font-size: 16px;
font-weight: 500;
transition: all 0.3s ease;
outline: none;
}
.dzmy-controls input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.dzmy-controls input:focus {
border-color: var(--dzmy-accent-color);
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.1);
background: rgba(0, 0, 0, 0.4);
}
.dzmy-controls input:hover {
border-color: rgba(255, 215, 0, 0.3);
}
/* 控制区域内的按钮 */
.dzmy-controls .action-btn {
margin-right: 0;
opacity: 1;
flex-shrink: 0;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border-radius: var(--dzmy-border-radius);
background: linear-gradient(135deg, #f32152 0%, #ff0000 100%);
border: none;
color: white;
cursor: pointer;
transition: all 0.3s ease;
min-width: 100px;
box-shadow: 0 4px 12px rgba(243, 33, 82, 0.3);
}
.dzmy-controls .action-btn:hover {
opacity: 0.95;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(243, 33, 82, 0.4);
}
.dzmy-controls .action-btn:active {
opacity: 0.9;
transform: translateY(0);
box-shadow: 0 2px 8px rgba(243, 33, 82, 0.3);
}
.dzmy-controls .action-btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(243, 33, 82, 0.2);
}
/* 响应式设计 */
/* 平板设备 */
@media (max-width: 768px) {
:root {
--dzmy-container-height: 400px;
--dzmy-img-width: 140px;
--dzmy-img-height: 105px;
--dzmy-border-radius: var(--dzmy-border-radius);
}
.dzmy-count {
font-size: 16px;
top: 16px;
left: 16px;
padding: 6px 12px;
}
.tip-item {
font-size: 22px;
}
.dzmy-controls {
padding: 14px 20px;
gap: 12px;
}
.dzmy-controls input {
min-width: 120px;
max-width: 180px;
padding: 10px 14px;
font-size: 15px;
}
.dzmy-controls .action-btn {
padding: 10px 20px;
font-size: 15px;
min-width: 90px;
}
}
/* 大手机设备 */
@media (max-width: 480px) {
:root {
--dzmy-container-height: 350px;
--dzmy-img-width: 120px;
--dzmy-img-height: 90px;
--dzmy-border-radius: 8px;
}
.dzmy-count {
font-size: 14px;
top: 12px;
left: 12px;
padding: 5px 10px;
}
.tip-item {
font-size: 20px;
}
.dzmy-controls {
flex-direction: column;
align-items: stretch;
padding: 12px 16px;
gap: 10px;
}
.dzmy-controls input {
min-width: 100%;
max-width: 100%;
padding: 10px 12px;
font-size: 14px;
}
.dzmy-controls .action-btn {
width: 100%;
padding: 10px 16px;
font-size: 14px;
min-width: auto;
}
}
/* 小手机设备 */
@media (max-width: 360px) {
:root {
--dzmy-container-height: 300px;
--dzmy-img-width: 100px;
--dzmy-img-height: 75px;
--dzmy-border-radius: 8px;
}
.dzmy-count {
font-size: 12px;
top: 10px;
left: 10px;
padding: 4px 8px;
}
.tip-item {
font-size: 18px;
}
.dzmy-controls {
padding: 10px 12px;
gap: 8px;
}
.dzmy-controls input {
padding: 8px 10px;
font-size: 13px;
}
.dzmy-controls .action-btn {
padding: 8px 12px;
font-size: 13px;
}
}
/* 超小手机设备 */
@media (max-width: 320px) {
:root {
--dzmy-container-height: 280px;
--dzmy-img-width: 90px;
--dzmy-img-height: 68px;
}
.dzmy-count {
font-size: 11px;
}
.tip-item {
font-size: 16px;
}
}
/* 横屏模式优化 */
@media (max-height: 500px) and (orientation: landscape) {
:root {
--dzmy-container-height: 250px;
--dzmy-img-width: 100px;
--dzmy-img-height: 75px;
}
.dzmy-controls {
flex-direction: row;
padding: 8px 12px;
}
.dzmy-controls input {
min-width: 120px;
max-width: 150px;
}
.dzmy-controls .action-btn {
min-width: 80px;
padding: 6px 12px;
}
}
-285
View File
@@ -1,285 +0,0 @@
import React, {useState, useCallback, useEffect, useRef} from 'react';
import dzmyImg from '../assets/images/dzmy.png';
import './ElectronicWoodenFish.css';
import storageAdapter from "../utils/storageAdapter";
const MAX_TIP_LIST_SIZE = 50; // 最大提示数量限制
const ElectronicWoodenFishPage = () => {
const [active, setActive] = useState(false);
const [count, setCount] = useState(0);
const [tipList, setTipList] = useState([]);
const [countName, setCountName] = useState('金钱');
// Refs for cleanup and avoiding stale closures
const timerRef = useRef(null);
const countNameRef = useRef(countName);
const tipTimersRef = useRef(new Map());
const firstRenderRef = useRef(true);
const storageTimerRef = useRef(null);
useEffect(() => {
let isMounted = true;
const init = async () => {
try {
const [savedCount, savedCountName] = await Promise.all([
storageAdapter.get('count'),
storageAdapter.get('countName')
]);
if (!isMounted) return;
// 处理 count:确保是有效数字
let validCount = 0;
console.log('初始化存储数据:', savedCount, savedCountName);
if (savedCount !== undefined && savedCount !== null) {
const num = Number(savedCount);
if (!isNaN(num) && isFinite(num) && num >= 0) {
validCount = Math.floor(num); // 取整,确保是整数
}
}
setCount(validCount);
// 如果存储的值无效或不存在,更新存储
if (savedCount !== validCount) {
await storageAdapter.set('count', validCount);
}
// 处理 countName:确保是有效非空字符串
let validCountName = '金钱';
if (typeof savedCountName === 'string') {
const trimmed = savedCountName.trim();
// 拒绝空字符串、'undefined'、'null'等无效值
if (trimmed.length > 0 &&
trimmed.toLowerCase() !== 'undefined' &&
trimmed.toLowerCase() !== 'null' &&
trimmed !== 'NaN') {
validCountName = trimmed;
}
}
setCountName(validCountName);
// 如果存储的值无效或不存在,更新存储
if (savedCountName !== validCountName) {
await storageAdapter.set('countName', validCountName);
}
} catch (error) {
console.error('初始化存储数据失败:', error);
// 静默失败,使用默认值
if (isMounted) {
setCount(0);
setCountName('金钱');
}
}
};
init();
return () => {
isMounted = false;
};
}, []);
// 同步 countName 到 ref,避免闭包问题
useEffect(() => {
countNameRef.current = countName;
}, [countName]);
// 存储 countName 变化(防抖处理)
useEffect(() => {
if (firstRenderRef.current) {
firstRenderRef.current = false;
return;
}
// 清理之前的定时器
if (storageTimerRef.current) {
clearTimeout(storageTimerRef.current);
}
// 防抖存储:500ms 后存储
storageTimerRef.current = setTimeout(async () => {
try {
await storageAdapter.set('countName', countName);
} catch (error) {
console.error('存储 countName 失败:', error);
}
}, 500);
return () => {
if (storageTimerRef.current) {
clearTimeout(storageTimerRef.current);
}
};
}, [countName]);
// 组件卸载时清理所有定时器
useEffect(() => {
return () => {
// 清理动画定时器
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// 清理所有 tip 定时器
tipTimersRef.current.forEach((timer) => {
clearTimeout(timer);
});
tipTimersRef.current.clear();
// 清理存储定时器
if (storageTimerRef.current) {
clearTimeout(storageTimerRef.current);
storageTimerRef.current = null;
}
};
}, []);
const clickAnimation = useCallback(async () => {
try {
// 1. 木鱼缩放动画 - 使用 requestAnimationFrame 优化时序
setActive(true);
// 清理之前的动画定时器
if (timerRef.current) {
clearTimeout(timerRef.current);
}
// 设置动画结束定时器
timerRef.current = setTimeout(() => {
setActive(false);
timerRef.current = null;
}, 100);
// 2. 更新计数并获取新值,同时执行存储
setCount((prev) => {
const newCount = prev + 1;
// 异步存储更新(不阻塞 UI
const storagePromises = [
storageAdapter.set('count', newCount),
storageAdapter.set('countName', countNameRef.current)
];
// 使用 Promise.all 并行存储,但忽略错误(静默失败)
Promise.all(storagePromises).catch(error => {
console.error('存储更新失败:', error);
// 可以选择重试或记录错误
});
return newCount;
});
// 3. 生成唯一的 Tip 对象
const tipId = `tip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const newTip = {
id: tipId,
// 使用当前 countName(通过 ref 获取最新值)
label: `${countNameRef.current} +1`
};
// 4. 添加到列表(限制最大长度)
setTipList((prev) => {
const newList = [...prev, newTip];
// 如果超过最大长度,移除最旧的 tip
if (newList.length > MAX_TIP_LIST_SIZE) {
const removedTip = newList.shift(); // 移除第一个元素
// 清理被移除 tip 的定时器
const removedTimer = tipTimersRef.current.get(removedTip.id);
if (removedTimer) {
clearTimeout(removedTimer);
tipTimersRef.current.delete(removedTip.id);
}
}
return newList;
});
// 5. 自动移除:500ms 后删除该特定 Tip
const removalTimer = setTimeout(() => {
setTipList((prev) => prev.filter((t) => t.id !== tipId));
tipTimersRef.current.delete(tipId);
}, 500);
tipTimersRef.current.set(tipId, removalTimer);
} catch (error) {
console.error('点击动画执行失败:', error);
// 确保动画状态重置
setActive(false);
}
}, []); // 无依赖,使用 refs 获取最新状态
const resetAll = async () => {
try {
// 清理所有 tip 定时器
tipTimersRef.current.forEach((timer) => {
clearTimeout(timer);
});
tipTimersRef.current.clear();
// 清理动画定时器
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// 重置状态
setCount(0);
setTipList([]);
setCountName('金钱');
// 同时更新 ref
countNameRef.current = '金钱';
// 异步存储重置(不阻塞 UI
const storagePromises = [
storageAdapter.set('count', 0),
storageAdapter.set('countName', '金钱')
];
Promise.all(storagePromises).catch(error => {
console.error('重置存储失败:', error);
});
} catch (error) {
console.error('重置操作失败:', error);
}
}
return (
<div>
<div className="dzmy-bg">
<div className="dzmy-count">{countName}: {count}</div>
<div className="dzmy-box">
<div className="tips">
{tipList.map((tip) => (
<div key={tip.id} className="tip-item">
{tip.label}
</div>
))}
</div>
<img
alt="电子木鱼"
src={dzmyImg}
className={`dzmy-img ${active ? 'active' : ''}`}
onClick={clickAnimation}
/>
</div>
</div>
<div className={'dzmy-controls'}>
<input
type="text"
value={countName}
onChange={(e) => setCountName(e.target.value)}
/>
<button
className={'action-btn'}
onClick={resetAll}>重置
</button>
</div>
</div>
);
};
export default ElectronicWoodenFishPage;
-15
View File
@@ -1,15 +0,0 @@
import {TimestampToDatetime} from "../components/TimestampToDatetime";
import {DatetimeToTimestamp} from "../components/DatetimeToTimestamp";
import {TimestampExecution} from "../components/TimestampExecution";
const TimestampPage = () => {
return (<div className="timestamp-utils">
<TimestampExecution/>
<TimestampToDatetime/>
<DatetimeToTimestamp/>
</div>);
};
export default TimestampPage;
-32
View File
@@ -1,32 +0,0 @@
import { Link } from 'react-router-dom';
const UserListPage = () => {
// Sample user data (replace with API calls later)
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
{ id: 3, name: 'Charlie', email: 'charlie@example.com' },
];
return (
<div className="page">
<h1>User List</h1>
<p>Click "Actions" to manage users.</p>
<ul className="user-list">
{users.map((user) => (
<li key={user.id}>
<strong>{user.name}</strong> ({user.email})
</li>
))}
</ul>
{/* Link to ActionsPage */}
<Link to="/actions" className="btn">
Go to Actions Page
</Link>
</div>
);
};
export default UserListPage;
-13
View File
@@ -1,13 +0,0 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
-5
View File
@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
-7
View File
@@ -1,7 +0,0 @@
import Dexie from "dexie";
export const db = new Dexie("testImagesDb");
db.version(1).stores({
images: '++id, created, source'
});
-30
View File
@@ -1,30 +0,0 @@
// 判断当前是否处于 Chrome 插件环境
const isExtension = typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local;
const storageAdapter = {
// 获取数据
get: async (key) => {
if (isExtension) {
const result = await chrome.storage.local.get([key]);
return result[key];
} else {
const item = localStorage.getItem(key);
try {
return JSON.parse(item); // 保持与插件版一致的对象处理
} catch {
return item;
}
}
},
// 设置数据
set: async (key, value) => {
if (isExtension) {
await chrome.storage.local.set({[key]: value});
} else {
localStorage.setItem(key, JSON.stringify(value));
}
}
};
export default storageAdapter;
-69
View File
@@ -1,69 +0,0 @@
export function formatDate(value, timezone = 'Asia/Shanghai') {
return (new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZone: timezone,
})).format(value);
}
export const TimezoneOptions = ({zones}) => {
return (zones.map((zone) => (<option key={zone} value={zone}>{zone}</option>)));
}
export const formatWithZone = (timestamp, zone = 'Asia/Shanghai', unit = 'milliseconds') => {
try {
const ms = unit === 'milliseconds' ? Number(timestamp) : Number(timestamp) * 1000;
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZone: zone,
}).format(ms);
} catch (e) {
return '格式错误';
}
}
export const getTimeZoneOffset = (timeZone) => {
const now = new Date();
const utc = new Date(now.toLocaleString('en-US', {timeZone: 'UTC'}));
const target = new Date(now.toLocaleString('en-US', {timeZone: timeZone}));
return target.getTime() - utc.getTime();
}
export const formatWithDate = (
date,
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
) => {
try {
// 创建日期对象
const dateObj = new Date(date);
// 检查日期是否有效
if (isNaN(dateObj.getTime())) {
return '无效的日期';
}
// 如果提供了时区,则需要特殊处理
if (timeZone) {
// 获取给定时区相对于UTC的时间差(毫秒)
const utc = dateObj.getTime() + dateObj.getTimezoneOffset() * 60000;
// 计算目标时区相对于UTC的偏移量
const targetOffset = getTimeZoneOffset(timeZone);
return utc + targetOffset;
} else {
return dateObj.getTime();
}
} catch (error) {
return '日期转换错误: ' + error.message;
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
/* --- --- */
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"module": "ESNext", // 支持 import.meta
"moduleResolution": "Bundler", // 或者用 "Node"
/* --- 1. () --- */
// 开启所有严格检查,包括 noImplicitAny。
// 这能帮你捕获 "timer" 隐式 any 等错误,强制你写出更高质量的代码。
"strict": true,
/* --- 2. --- */
// 声明了但没使用的变量报错(防止代码冗余)
"noUnusedLocals": true,
// 函数参数没使用报错
"noUnusedParameters": true,
// 函数必须有返回值,防止遗漏 return
"noImplicitReturns": true,
// switch 语句没有 break 时报错
"noFallthroughCasesInSwitch": true,
/* --- 3. --- */
// 设置基础目录,方便解析相对路径
"baseUrl": ".",
// 确保包含 DOM 类型(解决 setTimeout、document 等报错)
"lib": ["DOM", "DOM.Iterable", "ESNext"],
// 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容
"target": "ESNext",
/* --- 4. () --- */
// 如果你的 @/utils/... 爆红,可以手动添加这个映射。
// WXT 通常会自动处理,但在这里显式声明有助于 VS Code 智能提示。
"paths": {
"@/*": ["./*"]
},
"types": ["chrome", "webextension-polyfill"],
"noImplicitAny": false
},
// 确保包含你的源代码目录
"include": [
"entrypoints/**/*",
"components/**/*",
"utils/**/*",
"assets/**/*",
"hooks/**/*",
".wxt/types/**/*.ts",
".wxt/types/*.d.ts",
"components",
"eslint.config.ts"
],
"exclude": [
"node_modules",
".wxt",
"components/__tests__",
"**/*.test.tsx",
"**/*.test.ts",
"vitest.config.ts",
"vitest.setup.ts"
]
}
+44
View File
@@ -0,0 +1,44 @@
export type PageType = 'timestamp' | 'storageCleaner';
export interface StorageSchema {
'app/currentRoute': PageType;
'app/visiblePages': PageType[];
'app/lastRoute': string;
'app/theme': string;
'storageCleaner/preferences': StorageCleanerPreferences;
}
export interface StorageCleanerPreferences {
autoRefresh: boolean;
selectedTypes: StorageCleanerOptions;
}
export interface StorageCleanerOptions {
localStorage: boolean;
sessionStorage: boolean;
indexedDB: boolean;
cookies: boolean;
cacheStorage: boolean;
serviceWorkers: boolean;
}
export type StorageCleanResult =
| {
success: true;
count: number;
}
| {
success: false;
error: string;
};
export interface CleaningResult {
success: boolean;
error?: string;
localStorage?: StorageCleanResult;
sessionStorage?: StorageCleanResult;
indexedDB?: StorageCleanResult;
cookies?: StorageCleanResult;
cacheStorage?: StorageCleanResult;
serviceWorkers?: StorageCleanResult;
}
+43
View File
@@ -0,0 +1,43 @@
import { StorageSchema } from 'types/storage';
class StorageUtils {
async get<K extends keyof StorageSchema>(key: K): Promise<StorageSchema[K]>;
async get<K extends keyof StorageSchema>(
key: K,
defaultValue?: StorageSchema[K],
): Promise<StorageSchema[K] | undefined>;
/**
* 获取值
* @param key
* @param defaultValue
* @returns
*/
async get<K extends keyof StorageSchema>(
key: K,
defaultValue?: StorageSchema[K],
): Promise<StorageSchema[K] | undefined> {
const result = await chrome.storage.local.get([key]);
return (result[key] ?? defaultValue) as StorageSchema[K] | undefined;
}
/**
* 设置值
* @param key
* @param value
*/
async set<K extends keyof StorageSchema>(key: K, value: StorageSchema[K]): Promise<void> {
await chrome.storage.local.set({ [key]: value });
}
/**
* 删除值
* @param key
*/
async remove(key: keyof StorageSchema): Promise<void> {
await chrome.storage.local.remove([key]);
}
}
export const storageUtil = new StorageUtils();
+13
View File
@@ -0,0 +1,13 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
export default dayjs;
+8
View File
@@ -0,0 +1,8 @@
import { defineExtensionMessaging } from '@webext-core/messaging';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface ProtocolMap {
// Placeholder - 扩展消息协议
}
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
+246
View File
@@ -0,0 +1,246 @@
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
const RESTRICTED_PROTOCOLS = [
'chrome:',
'chrome-extension:',
'about:',
'edge:',
'view-source:',
'file:',
'data:',
] as const;
export async function getCurrentTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
return tab;
}
export function isRestrictedUrl(url?: string): boolean {
if (!url) return true;
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
}
export async function clearCookies(url: string): Promise<StorageCleanResult> {
try {
const cookies = await chrome.cookies.getAll({ url });
for (const cookie of cookies) {
await chrome.cookies.remove({
url,
name: cookie.name,
storeId: cookie.storeId,
});
}
return { success: true, count: cookies.length };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const count = localStorage.length;
localStorage.clear();
return { count };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const count = sessionStorage.length;
sessionStorage.clear();
return { count };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
if (typeof indexedDB.databases === 'function') {
const databases = await indexedDB.databases();
let count = 0;
for (const db of databases) {
if (db.name) {
const dbName = db.name as string;
await new Promise<void>((resolve, reject) => {
const deleteReq = indexedDB.deleteDatabase(dbName);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', dbName);
};
deleteReq.onsuccess = () => resolve();
deleteReq.onerror = () => reject();
});
count++;
}
}
return { count };
}
return { error: 'databases_api_unavailable' };
},
});
if (result?.result && typeof result.result === 'object') {
if ('error' in result.result) {
return { success: false, error: String(result.result.error) };
}
if ('count' in result.result) {
return { success: true, count: result.result.count };
}
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
if ('caches' in window) {
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
return { count: cacheNames.length };
}
return { count: 0 };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function injectUnregisterServiceWorkers(
tabId: number,
): Promise<StorageCleanResult> {
try {
const [result] = await chrome.scripting.executeScript({
target: { tabId },
func: async () => {
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
return { count: registrations.length };
}
return { count: 0 };
},
});
if (result?.result && typeof result.result === 'object' && 'count' in result.result) {
return { success: true, count: result.result.count };
}
return { success: false, error: 'No result returned' };
} catch (error) {
return { success: false, error: String(error) };
}
}
export async function clearStorage(
tabId: number,
url: string,
options: StorageCleanerOptions,
): Promise<CleaningResult> {
const result: CleaningResult = { success: true };
if (options.localStorage) {
result.localStorage = await injectClearLocalStorage(tabId);
}
if (options.sessionStorage) {
result.sessionStorage = await injectClearSessionStorage(tabId);
}
if (options.indexedDB) {
result.indexedDB = await injectClearIndexedDB(tabId);
}
if (options.cookies) {
result.cookies = await clearCookies(url);
}
if (options.cacheStorage) {
result.cacheStorage = await injectClearCacheStorage(tabId);
}
if (options.serviceWorkers) {
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
}
// Check if any operation failed
const failures = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === false,
);
if (failures.length > 0) {
result.success = false;
result.error = '部分清理失败';
}
return result;
}
export function formatCleaningResult(result: CleaningResult): string {
const parts: string[] = [];
if (result.localStorage?.success) {
parts.push(`${result.localStorage.count} 个 localStorage`);
}
if (result.sessionStorage?.success) {
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
}
if (result.indexedDB?.success) {
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
}
if (result.cookies?.success) {
parts.push(`${result.cookies.count} 个 Cookies`);
}
if (result.cacheStorage?.success) {
parts.push(`${result.cacheStorage.count} 个 Cache`);
}
if (result.serviceWorkers?.success) {
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
}
if (parts.length === 0) {
return '该页面没有可清理的存储数据';
}
return `清理了 ${parts.join(', ')}`;
}
export function isEmptyResult(result: CleaningResult): boolean {
const values = Object.values(result).filter(
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
);
return values.length === 0;
}
+24
View File
@@ -0,0 +1,24 @@
import '@testing-library/jest-dom';
import { vi } from 'vitest';
// 模拟 chrome storage API
Object.defineProperty(global, 'chrome', {
value: {
storage: {
local: {
get: vi.fn(),
set: vi.fn(),
remove: vi.fn(),
},
},
},
writable: true,
});
// 模拟 navigator.clipboard
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
writable: true,
});
+6
View File
@@ -0,0 +1,6 @@
import { defineWebExtConfig } from 'wxt';
export default defineWebExtConfig({
startUrls: ['https://www.baidu.com', 'chrome://extensions/'],
chromiumArgs: ['chrome://extensions/'],
});
+48
View File
@@ -0,0 +1,48 @@
import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifest: {
name: 'Testing Tools',
version: '1.0',
description: '测试工具',
permissions: [
'storage',
'unlimitedStorage',
'clipboardWrite',
'activeTab',
'scripting',
'tabs',
'debugger',
'cookies',
],
host_permissions: ['<all_urls>'],
action: {
default_title: 'Testing Tools',
},
options_ui: {
open_in_tab: true,
},
// 将 favicon.ico 放入 public/ 文件夹中
icons: {
16: 'favicon.ico',
48: 'favicon.ico',
128: 'favicon.ico',
},
},
vite: () => ({
build: {
// 1. 切换压缩器为 terser
minify: 'terser',
// 2. 配置 terser 强制转义所有非 ASCII 字符
terserOptions: {
format: {
ascii_only: true,
comments: false,
},
},
},
}),
});