Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77e74e31d3 | |||
| c70b5d1ab5 | |||
| 1fdbd22167 | |||
| 671c7c677f | |||
| 3bac87717f | |||
| 33b16852e7 | |||
| b4a3bbfebb | |||
| b56674b83b | |||
| a0854da80c | |||
| 63c8fe805e | |||
| 83dbf0b3e9 | |||
| 2faa66a880 | |||
| de3ae93ea9 | |||
| 480af5f969 | |||
| f530cfa7ac | |||
| 1e4a9375df | |||
| 8f53849429 | |||
| a7c6fb6339 | |||
| 6f0031cf74 | |||
| 11af171212 | |||
| d2dc0057b3 | |||
| a9021b6397 | |||
| 050621d3dc | |||
| 9db9481ef4 | |||
| dec0da2ff3 | |||
| 4d52cbb12f | |||
| 789705c33b | |||
| 9ff6b8985c | |||
| c9f8fe4d29 | |||
| 6458500d5a | |||
| f0bb1b21ab | |||
| 6d2b553ca4 | |||
| c848003214 | |||
| 80e510225c | |||
| 19b7ab141a | |||
| c01659c9f9 | |||
| 3ece5e2e96 | |||
| 50034331bc | |||
| 22985f4c18 | |||
| ee4d328b9c | |||
| a93a8b3e9a | |||
| 38b7d16c31 | |||
| a94ae7421e | |||
| 2c0df95104 | |||
| e6f41032c8 | |||
| 37f8ef9c2b | |||
| a6d85962b7 | |||
| 0b70c0c0c1 | |||
| 4213e70697 | |||
| 88a612f9cf | |||
| 0ff33cbeb3 | |||
| ed5e90f0fc | |||
| 92026ee7fa | |||
| 710bffd2f8 | |||
| 4edb2f1a47 | |||
| b33194bd2d | |||
| 9c84b7adc0 | |||
| a53b58a007 | |||
| 49aaab6e2c | |||
| b6eeb74fed | |||
| 1c258e2bf4 | |||
| de8e6c6139 |
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# Logs
|
||||||
|
logs
|
||||||
# dependencies
|
*.log
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.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?
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
@@ -3,17 +3,18 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": false,
|
"singleQuote": true,
|
||||||
"quoteProps": "as-needed",
|
|
||||||
"jsxSingleQuote": false,
|
"jsxSingleQuote": false,
|
||||||
"trailingComma": "none",
|
"trailingComma": "all",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"jsxBracketSameLine": false,
|
|
||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
"requirePragma": false,
|
"endOfLine": "lf",
|
||||||
"insertPragma": false,
|
"overrides": [
|
||||||
"proseWrap": "preserve",
|
{
|
||||||
"htmlWhitespaceSensitivity": "ignore",
|
"files": "*.json",
|
||||||
"endOfLine": "auto"
|
"options": {
|
||||||
}
|
"trailingComma": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 确保暂存文件符合规范
|
||||||
@@ -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
|
||||||
@@ -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 样式)的严格分离。
|
||||||
@@ -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.\
|
- **框架**: WXT (Web Extension Toolkit)
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
- **前端**: 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),仅供内部使用。
|
||||||
|
|||||||
@@ -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. 测试用户偏好持久化
|
||||||
|
|
||||||
|
## 后续优化
|
||||||
|
|
||||||
|
- 显示清理前的存储使用情况
|
||||||
|
- 支持批量清理多个标签页
|
||||||
|
- 支持自定义域名清理
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import '../.wxt/types/imports.d.ts';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['<all_urls>'],
|
||||||
|
runAt: 'document_start',
|
||||||
|
main() {
|
||||||
|
// Content script placeholder
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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'],
|
||||||
|
};
|
||||||
@@ -1,45 +1,50 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-extension-router-demo",
|
"name": "wxt-react-starter",
|
||||||
"version": "0.1.0",
|
"description": "manifest.json description",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"version": "0.0.0",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"type": "module",
|
||||||
"@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"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"dev": "wxt",
|
||||||
"build": "react-scripts build",
|
"dev:firefox": "wxt -b firefox",
|
||||||
"test": "react-scripts test",
|
"build": "wxt build",
|
||||||
"eject": "react-scripts eject"
|
"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": {
|
"dependencies": {
|
||||||
"extends": [
|
"@emotion/react": "^11.14.0",
|
||||||
"react-app",
|
"@emotion/styled": "^11.14.1",
|
||||||
"react-app/jest"
|
"@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": {
|
"devDependencies": {
|
||||||
"production": [
|
"@types/chrome": "^0.1.36",
|
||||||
">0.2%",
|
"@types/react": "^19.2.7",
|
||||||
"not dead",
|
"@types/react-dom": "^19.2.3",
|
||||||
"not op_mini all"
|
"@types/webextension-polyfill": "^0.12.4",
|
||||||
],
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"development": [
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"last 1 chrome version",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"last 1 firefox version",
|
"@wxt-dev/module-react": "^1.1.5",
|
||||||
"last 1 safari version"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 559 B |
|
After Width: | Height: | Size: 916 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -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 |
@@ -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;
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 38 KiB |
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 +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 |
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import Dexie from "dexie";
|
|
||||||
|
|
||||||
export const db = new Dexie("testImagesDb");
|
|
||||||
|
|
||||||
db.version(1).stores({
|
|
||||||
images: '++id, created, source'
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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>();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineWebExtConfig } from 'wxt';
|
||||||
|
|
||||||
|
export default defineWebExtConfig({
|
||||||
|
startUrls: ['https://www.baidu.com', 'chrome://extensions/'],
|
||||||
|
chromiumArgs: ['chrome://extensions/'],
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||