feat: complete testing-tool browser extension with timestamp converter and storage cleaner

This commit is contained in:
雨霖铃
2025-11-11 01:07:43 +08:00
commit f4c2f6d374
42 changed files with 13902 additions and 0 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"
]
}
}
+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
+27
View File
@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
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
+20
View File
@@ -0,0 +1,20 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"arrowParens": "always",
"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 样式)的严格分离。
+121
View File
@@ -0,0 +1,121 @@
# Testing Tools Browser Extension
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。
## 项目概述
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
## 功能特性
### 时间戳转换工具
- 实时显示当前时间戳(毫秒/秒可切换)
- 日期与时间戳之间的双向转换
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
- 一键复制转换结果
- 输入验证和错误提示
## 技术栈
- **框架**: WXT (Web Extension Toolkit)
- **前端**: React 19 + TypeScript
- **UI 库**: Material UI
- **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装)
## 项目结构
```
├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
│ ├── options/ # 选项页面
│ ├── background.ts # 后台脚本
│ └── content.ts # 内容脚本
├── utils/ # 工具函数(存储、日期处理、消息通信)
├── types/ # TypeScript 类型定义
├── public/ # 静态资源
├── wxt.config.ts # WXT 配置文件
├── package.json # 项目依赖和脚本
└── README.md # 项目说明文档
```
## 开发环境要求
- Node.js >= 18
- npm 或 yarn
## 安装与运行
### 1. 安装依赖
```bash
npm install
```
### 2. 开发模式
```bash
# Chrome 浏览器
npm run dev
# Firefox 浏览器
npm run dev:firefox
```
### 3. 构建生产版本
```bash
# Chrome 浏览器
npm run build
# Firefox 浏览器
npm run build:firefox
```
### 4. 打包分发
```bash
# Chrome 浏览器
npm run zip
# Firefox
npm run zip:firefox
```
### 5. 其他命令
```bash
npm run compile # TypeScript 类型检查
npm run lint # ESLint 代码检查
```
## 权限说明
扩展请求以下权限:
- `storage``unlimitedStorage` - 本地数据存储
- `clipboardWrite` - 剪贴板写入(复制功能)
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
- `debugger` - 调试器权限
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
## 主要依赖
- `react`, `react-dom` - 前端框架
- `@mui/material` - UI 组件库
- `dayjs` - 日期处理
- `@webext-core/messaging` - 扩展消息通信
## 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
此项目为私有项目 (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>
);
}
+18
View File
@@ -0,0 +1,18 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit; /* 先全部重置为继承大小 */
font-weight: inherit;
}
+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'],
};
+10394
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
{
"name": "wxt-react-starter",
"description": "manifest.json description",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"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"
},
"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"
},
"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"
}
}
Binary file not shown.

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

+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

+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,
},
},
},
}),
});