* feat: optimize popup standalone window layout and enhance storage cleaner synchronization

* docs: 更新README文档并删除过时文件

- 更新README文档,添加项目结构、功能特性和路由系统等详细信息
- 删除不再使用的文档文件,包括CLAUDE.md、GEMINI.md和多个设计规范文档
- 清理项目中的过时配置文件和计划文档

* feat: 添加 Vitest 测试框架和组件测试

- 添加 Vitest 配置 (vitest.config.ts, vitest.setup.ts)
- 创建组件测试: Button, ToolCard, GlobalSnackbar, TopBar, RouterContainer, StorageCleanerConfirm
- 创建工具测试: routes, storageCleaner
- 修复 background.ts 监听器参数问题
- 修复 options/App.tsx 硬编码默认值
- 更新 lint-staged.config.mjs (添加 .mjs 支持, 添加 --no-warn-ignored)
- 更新 tsconfig.json (添加测试类型支持, 移除测试文件排除)
- 更新 package.json (添加测试脚本和依赖)

* fix: 修复 StorageCleanerPage Chrome API 监听器内存泄漏

使用 useRef 模式存储 loadInfo 函数引用,避免依赖数组变化导致的监听器重复注册问题

* refactor(popup): 优化 OpenUrl 页面样式和导航逻辑

重构 OpenUrl 页面输入框样式,改进聚焦状态效果
移除 RouterProvider 依赖,直接通过存储设置侧边栏路由
在 OpenUrlViewer 页面添加加载状态指示器和错误处理
监听存储变化实现 URL 自动更新

* feat(ui): 优化存储清理页面UI和交互效果

重构存储清理页面组件,增强视觉层次和交互体验:
- 使用新的错误提示样式和布局
- 改进选项卡片样式,增加悬停动画和选中状态
- 调整整体间距和排版,提升视觉一致性
- 添加微交互效果如悬停缩放和阴影
- 优化颜色方案和过渡动画
- 统一组件尺寸和字体层级

* feat: 添加二维码工具页面,支持URL转二维码和二维码解析功能

* chore: update package-lock.json (npm audit fix)

* refactor(主题): 将页面样式抽离到统一配置文件

将各页面的颜色和样式配置抽离到config/pageTheme.ts中统一管理
优化测试用例中使用each替代forEach
更新路由测试以包含新的qrCode页面

* feat(二维码页面): 添加复制二维码功能并优化样式

添加复制二维码到剪贴板的功能,并调整按钮布局和样式。同时将 ContentCopyIcon 导入位置调整到其他图标导入之后,并修复缩进问题。在 tsconfig.json 中添加 vitest/globals 类型支持。

* feat(theme): 为所有页面添加统一的背景色和卡片背景色

为应用中的所有页面添加了统一的浅灰色背景(#f5f5f5)和白色卡片背景(#ffffff),以保持视觉一致性。修改了ToolCard组件以支持自定义卡片背景色,并更新了所有相关页面使用新的主题配置。

* feat: 添加复制按钮组件并优化现有复制功能

refactor(utils): 创建剪贴板工具函数
feat(components): 新增可复用的CopyButton组件
refactor(pages): 在QrCodePage和TimestampPage中使用CopyButton
style: 格式化代码并调整部分样式

* refactor(存储): 统一qrCode相关存储键名

将'qrCode/expanded'重命名为'qrCode/qrExpanded'以保持命名一致性

* feat: 添加二维码工具功能并更新项目配置

- 新增二维码工具页面及相关组件和工具函数
- 添加 MIT 许可证文件
- 更新 package.json 配置为公开项目
- 更新 README 文档说明新功能
This commit is contained in:
LingandRX
2026-04-19 15:31:14 +08:00
committed by GitHub
parent bbd0507bcd
commit be5e2f02ee
43 changed files with 4943 additions and 2483 deletions
-19
View File
@@ -1,19 +0,0 @@
{
"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",
"Skill(code-review:code-review)",
"Bash(grep -E \"\\\\.\\(md|txt\\)$\")",
"Bash(pkill -f \"wxt\")",
"Bash(python3 -m json.tool)",
"Bash(git restore:*)"
]
}
}
-183
View File
@@ -1,183 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Quick Start
A browser extension built with the WXT framework, providing timestamp conversion and storage cleaning tools.
### Essential Commands
| Command | Purpose |
| ----------------------- | ------------------------------------------------------ |
| `npm install` | Install dependencies (runs `wxt prepare` post-install) |
| `npm run dev` | Start development mode for Chrome |
| `npm run dev:firefox` | Start development mode for Firefox |
| `npm run build` | Build production version for Chrome |
| `npm run build:firefox` | Build production version for Firefox |
| `npm run zip` | Package Chrome extension |
| `npm run zip:firefox` | Package Firefox extension |
| `npm run compile` | TypeScript type checking (no file generation) |
| `npm run lint` | Run ESLint with zero warnings allowed |
### Setup
- Dependencies install automatically runs `wxt prepare` via postinstall hook
- Husky Git hooks are initialized via `prepare` script
## Architecture Overview
### Tech Stack
- **Framework**: WXT (Web Extension Toolkit)
- **Frontend**: React 19 + TypeScript
- **UI Library**: Material UI (MUI)
- **Date Handling**: dayjs (with UTC and timezone plugins)
- **Communication**: @webext-core/messaging
- **Storage**: Chrome Storage API with type-safe wrapper
### Directory Structure
```
entrypoints/ # Browser extension entry points
├── background.ts # Background script (injects content scripts)
├── content.ts # Content script (injected into pages)
├── popup/ # Extension popup interface
│ ├── App.tsx # Popup main application (handles routing)
│ ├── main.tsx # Popup entry point
│ ├── index.html # Popup HTML
│ └── pages/ # Popup pages
│ ├── TimestampPage.tsx # Timestamp conversion
│ └── StorageCleanerPage.tsx # Storage cleaning
└── options/ # Options page (static HTML)
utils/ # Utility functions
types/ # TypeScript type definitions
public/ # Static assets
```
### Extension Entry Points
- **Background Script**: Listens for install/update events, injects content scripts into valid tabs
- **Content Script**: Matches all URLs (`<all_urls>`), runs at document start (currently placeholder)
- **Popup**: Main interface with tab-based navigation between timestamp conversion and storage cleaning
- **Options Page**: Static HTML page, can be extended as settings interface
## Core Features
### Timestamp Conversion Tool (`entrypoints/popup/pages/TimestampPage.tsx`)
- Real-time current timestamp display (milliseconds/seconds toggle)
- Timestamp ↔ date/time conversion
- Support for multiple timezones (Asia/Shanghai, America/New_York, Europe/London)
- One-click copy functionality
- Input validation and error handling
### Storage Cleaning Tool (`entrypoints/popup/pages/StorageCleanerPage.tsx`)
- Automatically reads current domain
- Cleans multiple storage types: localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, Service Workers
- User-selectable storage types (all selected by default)
- Confirmation dialog to prevent accidental cleaning
- Cleaning result statistics display
- Auto-refresh page after cleaning option
- User preferences persistence
### Data Storage
Uses Chrome Storage API with type-safe wrapper (`utils/chromeStorage.ts`):
- **Storage Schema** (`types/storage.d.ts`): Interface-based type definitions
- **Current storage keys**:
- `app/currentRoute`: Current active page route (default: 'timestamp')
- `app/visiblePages`: List of visible pages (default: ['timestamp', 'storageCleaner'])
- `app/lastRoute`: Last accessed route (legacy)
- `app/theme`: Theme settings
- `storageCleaner/preferences`: Storage cleaner preferences (autoRefresh, selectedTypes)
## Development Workflow
### Browser Compatibility
- Supports Chrome and Firefox browsers
- Uses WXT framework to abstract browser differences
### Code Quality
- **ESLint**: Zero warnings enforced (`npm run lint`)
- **Husky**: Git hook management
- **lint-staged**: Ensures staged files comply (ESLint + TypeScript + Prettier)
- **Prettier**: Code formatting (100 char line width, 2 space indent, single quotes, trailing comma)
### TypeScript Configuration
- Strict mode enabled (`strict: true`)
- `noImplicitAny` set to `false` (allows implicit any)
- Unused variables/parameters cause errors (`noUnusedLocals`, `noUnusedParameters`)
- Module resolution mode: Bundler
- Path alias: `@/*` maps to project root
- Excludes test files from type checking
### Path Aliases
- Use `@/` prefix for project-relative imports (e.g., `@/utils/chromeStorage`)
- Configured in `tsconfig.json` paths
## Configuration & Implementation
### `wxt.config.ts`
- Enables React module (`@wxt-dev/module-react`)
- Configures manifest permissions and host_permissions
- Uses Terser compression (forces ASCII encoding)
- Configures icons and options page
### Manifest Permissions
```typescript
permissions: [
'storage', // Chrome Storage
'unlimitedStorage', // Unlimited storage
'clipboardWrite', // Clipboard write (copy functionality)
'activeTab', // Current tab access
'scripting', // Script injection
'tabs', // Tab management
'debugger', // Debugger permissions
'cookies', // Cookies access (added for storage cleaning)
],
host_permissions: ['<all_urls>'] // Access all websites
```
### Storage Cleaning Implementation Details
- **Cookies**: Uses `chrome.cookies` API directly in extension context
- **Other storage types**: Uses `chrome.scripting.executeScript` to inject cleaning scripts into page context
- **Restricted page filtering**: chrome://, about://, edge://, view-source://, file://, data://
- **IndexedDB**: Uses `indexedDB.databases()` to get database list, handles `onblocked` events
- **Service Workers**: Unregisters to prevent re-caching
- **Cache Storage**: Uses `caches` API to clear all caches
### Messaging System
- Uses `@webext-core/messaging` library for type-safe extension communication
- Defined in `utils/messages.tsx`
- Current ProtocolMap is empty (reserved for future use)
## CI/CD & Project Context
### GitHub Actions Workflow (`.github/workflows/node.js.yml`)
- Triggers on push to main branch or pull requests
- Uses Node.js 20.x and 22.x for multi-version testing
- Runs ESLint, TypeScript compilation, and build steps
- Test commands are currently commented (project has no tests)
### Project History
Recent refactoring streamlined the project:
- Removed recording and playback functionality
- Removed test pages
- Streamlined to single-page timestamp tool
- Renamed storage utility class to storageUtil
- Added storage cleaning functionality with persistent preferences
- Added route persistence for popup navigation
-72
View File
@@ -1,72 +0,0 @@
# Testing Tools 项目指南
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
## 1. 项目概览
- **名称**: Testing Tools
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
## 2. 项目结构
```text
├── .github/ # CI/CD 工作流
├── .husky/ # Git Hooks (pre-commit linting)
├── assets/ # 静态资源 (SVG 等)
├── components/ # 复用 UI 组件
├── entrypoints/ # 浏览器扩展入口点
│ ├── background.ts # 后台 Service Worker 逻辑
│ ├── content.ts # 内容脚本注入逻辑
│ ├── popup/ # 扩展弹出层 (主要功能区)
│ └── options/ # 扩展选项页面
├── types/ # 全局 TypeScript 类型声明
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
├── wxt.config.ts # WXT 框架与 Manifest 配置
└── package.json # 依赖管理与脚本
```
## 3. 开发规范与风格约定
### 3.1 UI 设计语言
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
- **MUI 定制**:
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
- **布局**: 优先使用 `Stack``Box` 进行布局,确保自上而下的操作流顺畅。
### 3.2 技术选型惯例
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
- **状态管理**: 优先使用 React 原生 `useState``useMemo`
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
## 4. 关键指令
### 4.1 开发与调试
- `npm run dev`: 启动 Chrome 扩展开发模式。
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
### 4.2 构建与检查
- `npm run build`: 构建生产版本。
- `npm run compile`: TypeScript 类型检查。
- `npm run lint`: ESLint 代码风格检查。
## 5. 权限与清单 (Manifest)
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
## 6. 维护者提示
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Testing Tools
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+127 -22
View File
@@ -1,13 +1,19 @@
# Testing Tools Browser Extension # Testing Tools Browser Extension
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具 这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能
## 项目概述 ## 项目概述
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。 Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
## 功能特性 ## 功能特性
### Dashboard 首页
- 卡片式工具展示
- 支持自定义工具排序和可见性
- 实时数据预览(时间戳等)
### 时间戳转换工具 ### 时间戳转换工具
- 实时显示当前时间戳(毫秒/秒可切换) - 实时显示当前时间戳(毫秒/秒可切换)
@@ -16,6 +22,37 @@ Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转
- 一键复制转换结果 - 一键复制转换结果
- 输入验证和错误提示 - 输入验证和错误提示
### 存储清理工具
- 自动读取当前域名
- 支持清理多种存储类型:
- localStorage
- sessionStorage
- IndexedDB
- Cookies
- Cache Storage
- Service Workers
- 可选择的清理类型(默认全选)
- 确认对话框防止误操作
- 清理结果统计
- 自动刷新页面选项
### URL 工具
- 保存常用 URL 列表
- 快速打开保存的 URL
- 支持 URL 验证和安全检查
- 内置 URL 查看器(iframe 沙箱模式)
### 二维码工具
- URL 转二维码(生成器)
- 二维码转 URL(解析器)
- 支持上传二维码图片解析
- 生成的二维码可下载
- 一键复制转换结果
- 卡片式布局,节省空间
## 技术栈 ## 技术栈
- **框架**: WXT (Web Extension Toolkit) - **框架**: WXT (Web Extension Toolkit)
@@ -24,23 +61,83 @@ Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转
- **日期处理**: dayjs (含 UTC 和时区插件) - **日期处理**: dayjs (含 UTC 和时区插件)
- **通信**: @webext-core/messaging - **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装) - **存储**: Chrome Storage API (类型安全封装)
- **二维码**: qrcode (生成) + jsqr (解析)
- **测试**: Vitest + Testing Library
## 项目结构 ## 项目结构
``` ```
├── components/ # 可复用 UI 组件
│ ├── Button.tsx
│ ├── CopyButton.tsx
│ ├── GlobalSnackbar.tsx
│ ├── RouterContainer.tsx
│ ├── StorageCleanerConfirm.tsx
│ ├── ToolCard.tsx
│ └── TopBar.tsx
├── config/ # 路由配置
│ └── routes.ts # 页面路由定义
├── entrypoints/ # 浏览器扩展入口点 ├── entrypoints/ # 浏览器扩展入口点
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面) │ ├── popup/ # 扩展弹窗界面
│ ├── options/ # 选项页面 │ ├── App.tsx
│ ├── background.ts # 后台脚本 │ ├── main.tsx
│ └── content.ts # 内容脚本 │ └── pages/ # 页面组件
├── utils/ # 工具函数(存储、日期处理、消息通信) │ │ ├── DashboardPage.tsx
├── types/ # TypeScript 类型定义 │ │ ├── OpenUrlPage.tsx
├── public/ # 静态资源 │ │ ├── OpenUrlViewerPage.tsx
├── wxt.config.ts # WXT 配置文件 │ │ ├── QrCodePage.tsx
├── package.json # 项目依赖和脚本 │ │ ├── StorageCleanerPage.tsx
└── README.md # 项目说明文档 │ │ └── TimestampPage.tsx
│ ├── options/ # 选项页面
│ ├── sidepanel/ # 侧边栏
│ ├── background.ts # 后台脚本
│ └── content.ts # 内容脚本
├── providers/ # React Context providers
│ └── RouterProvider.tsx # 路由状态管理
├── types/ # TypeScript 类型定义
│ └── storage.d.ts
├── utils/ # 工具函数
│ ├── chromeStorage.ts
│ ├── clipboard.ts
│ ├── dayjs.ts
│ ├── messages.tsx
│ └── storageCleaner.ts
├── public/ # 静态资源
├── wxt.config.ts # WXT 配置文件
├── package.json
└── README.md
``` ```
## 路由系统
项目实现了灵活的路由系统,支持:
- **页面导航**: 在不同工具页面之间切换
- **路由同步**: 通过 Chrome Storage 同步路由状态
- **可见性控制**: 可配置显示哪些页面
- **页面排序**: 自定义工具卡片的显示顺序
### 页面类型 (PageType)
| 页面 | 说明 | 默认可见 |
| ---------------- | ---------- | -------- |
| `dashboard` | 首页 | ✓ |
| `timestamp` | 时间戳转换 | ✓ |
| `storageCleaner` | 存储清理 | ✓ |
| `openUrl` | URL 工具 | ✓ |
| `qrCode` | 二维码工具 | ✓ |
| `openUrlViewer` | URL 查看器 | ✗ |
## 扩展入口点
| 入口点 | 说明 |
| -------------- | ------------------------ |
| **popup** | 点击扩展图标弹出的界面 |
| **options** | 扩展选项页面 |
| **sidepanel** | 浏览器侧边栏 |
| **background** | 后台脚本(生命周期管理) |
| **content** | 内容脚本(注入到网页) |
## 开发环境要求 ## 开发环境要求
- Node.js >= 18 - Node.js >= 18
@@ -80,17 +177,25 @@ npm run build:firefox
# Chrome 浏览器 # Chrome 浏览器
npm run zip npm run zip
# Firefox # Firefox 浏览器
npm run zip:firefox npm run zip:firefox
``` ```
### 5. 其他命令 ### 5. 代码质量
```bash ```bash
npm run compile # TypeScript 类型检查 npm run compile # TypeScript 类型检查
npm run lint # ESLint 代码检查 npm run lint # ESLint 代码检查
``` ```
### 6. 测试
```bash
npm run test # 运行所有测试
npm run test:watch # 运行测试并监听文件变化
npm run test:coverage # 运行测试并生成覆盖率报告
```
## 权限说明 ## 权限说明
扩展请求以下权限: 扩展请求以下权限:
@@ -98,7 +203,8 @@ npm run lint # ESLint 代码检查
- `storage``unlimitedStorage` - 本地数据存储 - `storage``unlimitedStorage` - 本地数据存储
- `clipboardWrite` - 剪贴板写入(复制功能) - `clipboardWrite` - 剪贴板写入(复制功能)
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入 - `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
- `debugger` - 调试器权限 - `cookies` - Cookie 访问
- `sidePanel` - 侧边栏支持
- `<all_urls>` - 访问所有网站内容(内容脚本注入) - `<all_urls>` - 访问所有网站内容(内容脚本注入)
## 主要依赖 ## 主要依赖
@@ -107,15 +213,14 @@ npm run lint # ESLint 代码检查
- `@mui/material` - UI 组件库 - `@mui/material` - UI 组件库
- `dayjs` - 日期处理 - `dayjs` - 日期处理
- `@webext-core/messaging` - 扩展消息通信 - `@webext-core/messaging` - 扩展消息通信
- `vitest` - 测试框架
- `@testing-library/react` - React 组件测试
## 贡献指南 ## 浏览器兼容性
1. Fork 项目 - Chrome (推荐)
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) - Firefox
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证 ## 许可证
此项目为私有项目 (private: true),仅供内部使用 此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件
+77
View File
@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { IconButton, Tooltip } from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import { copyToClipboard } from '@/utils/clipboard';
interface CopyButtonProps {
text: string;
tooltip?: string;
size?: 'small' | 'medium' | 'large';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
style?: React.CSSProperties;
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void;
}
/**
* 复制按钮组件
* @param text 要复制的文本
* @param tooltip 提示信息
* @param size 按钮大小
* @param color 按钮颜色
* @returns 复制按钮组件
*/
export const CopyButton: React.FC<CopyButtonProps> = ({
text,
tooltip = '复制',
size = 'small',
color = 'primary',
style,
showMessage,
}) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (text) {
const success = await copyToClipboard(text, showMessage);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
} else {
showMessage?.('无内容可复制', { severity: 'error' });
}
};
return (
<Tooltip title={tooltip}>
<IconButton
size={size}
onClick={handleCopy}
style={style}
sx={{
color: copied ? 'success.main' : color,
bgcolor: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
'&:hover': {
bgcolor: copied
? 'success.main'
: typeof color === 'string' &&
!['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
? color
: `${color}.main`,
color: '#fff',
},
}}
>
{copied ? (
<CheckIcon fontSize={size === 'small' ? 'small' : 'medium'} />
) : (
<ContentCopyIcon fontSize={size === 'small' ? 'small' : 'medium'} />
)}
</IconButton>
</Tooltip>
);
};
export default CopyButton;
+26 -56
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Snackbar, Alert, type SxProps, type Theme, alpha } from '@mui/material'; import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error'; export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
@@ -61,39 +61,30 @@ export function GlobalSnackbar({
onClose, onClose,
severity = defaultProps.severity, severity = defaultProps.severity,
autoHideDuration = defaultProps.autoHideDuration, autoHideDuration = defaultProps.autoHideDuration,
anchorOrigin = defaultProps.anchorOrigin,
showAlert = defaultProps.showAlert, showAlert = defaultProps.showAlert,
hideIcon = defaultProps.hideIcon, hideIcon = defaultProps.hideIcon,
sx,
alertSx,
}: GlobalSnackbarProps) { }: GlobalSnackbarProps) {
// 共享的固定定位样式 // 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
const fixedSx: SxProps<Theme> = { return (
position: 'fixed', <Portal>
bottom: '24px !important', // 固定在视口底部
left: '50% !important',
transform: 'translateX(-50%) !important',
zIndex: (theme) => theme.zIndex.tooltip + 100,
maxWidth: '90%',
width: 'max-content',
};
if (showAlert) {
return (
<Snackbar <Snackbar
open={open} open={open}
autoHideDuration={autoHideDuration} autoHideDuration={autoHideDuration}
onClose={onClose} onClose={onClose}
anchorOrigin={anchorOrigin} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
disableWindowBlurListener disableWindowBlurListener
sx={[fixedSx, ...(Array.isArray(sx) ? sx : [sx])]} sx={{
zIndex: 999999,
// 确保距离底部的间距
bottom: { xs: '24px', sm: '24px' },
}}
> >
<Alert {showAlert ? (
severity={severity} <Alert
variant="filled" severity={severity}
icon={hideIcon ? false : undefined} variant="filled"
sx={[ icon={hideIcon ? false : undefined}
{ sx={{
borderRadius: '50px', borderRadius: '50px',
px: 2.5, px: 2.5,
py: 0.2, py: 0.2,
@@ -103,39 +94,18 @@ export function GlobalSnackbar({
justifyContent: 'center', justifyContent: 'center',
fontWeight: 800, fontWeight: 800,
fontSize: '0.75rem', fontSize: '0.75rem',
letterSpacing: '0.02em',
backgroundImage: 'none', backgroundImage: 'none',
boxShadow: (theme: Theme) => `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, boxShadow: (theme: Theme) =>
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
'& .MuiAlert-icon': { '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
mr: 0.5, '& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
fontSize: '1.1rem', }}
color: '#fff' >
}, {message}
'& .MuiAlert-message': { </Alert>
color: '#fff', ) : undefined}
padding: '6px 0',
textAlign: 'center'
}
},
...(Array.isArray(alertSx) ? alertSx : [alertSx]),
]}
>
{message}
</Alert>
</Snackbar> </Snackbar>
); </Portal>
}
return (
<Snackbar
open={open}
autoHideDuration={autoHideDuration}
onClose={onClose}
anchorOrigin={anchorOrigin}
message={message}
sx={[fixedSx, ...(Array.isArray(sx) ? sx : [sx])]}
/>
); );
} }
+3 -2
View File
@@ -11,15 +11,16 @@ interface ToolCardProps {
icon: React.ReactNode; icon: React.ReactNode;
onClick: () => void; onClick: () => void;
hasAI?: boolean; hasAI?: boolean;
cardBackgroundColor?: string;
} }
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI }: ToolCardProps) { export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) {
return ( return (
<Box <Box
onClick={onClick} onClick={onClick}
sx={{ sx={{
position: 'relative', position: 'relative',
bgcolor: 'background.paper', bgcolor: cardBackgroundColor,
borderRadius: 4, borderRadius: 4,
p: 2.5, p: 2.5,
cursor: 'pointer', cursor: 'pointer',
+17 -7
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material'; import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
@@ -7,10 +8,17 @@ import { useRouter } from '@/providers/RouterProvider';
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
const { currentPage, goBack } = useRouter(); const { currentPage, goBack } = useRouter();
const isDetachedMode = useMemo(() => {
return new URLSearchParams(window.location.search).get('mode') === 'detached';
}, []);
const handleDetach = () => { const handleDetach = () => {
// 弹出脱离窗口 (以独立面板形式打开当前 URL) // 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
const url = new URL(window.location.href);
url.searchParams.set('mode', 'detached');
chrome.windows.create({ chrome.windows.create({
url: window.location.href, url: url.toString(),
type: 'panel', type: 'panel',
width: 420, width: 420,
height: 600 height: 600
@@ -62,11 +70,13 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
</Typography> </Typography>
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}> <Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
<Tooltip title="独立窗口模式"> {!isDetachedMode && (
<IconButton size="small" onClick={handleDetach}> <Tooltip title="独立窗口模式">
<OpenInNewIcon sx={{ fontSize: 18 }} /> <IconButton size="small" onClick={handleDetach}>
</IconButton> <OpenInNewIcon sx={{ fontSize: 18 }} />
</Tooltip> </IconButton>
</Tooltip>
)}
<Tooltip title="设置"> <Tooltip title="设置">
<IconButton size="small" onClick={onOpenOptions}> <IconButton size="small" onClick={onOpenOptions}>
<SettingsIcon sx={{ fontSize: 18 }} /> <SettingsIcon sx={{ fontSize: 18 }} />
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../Button';
describe('Button Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render with default props', () => {
render(<Button>Click Me</Button>);
const button = screen.getByRole('button', { name: /click me/i });
expect(button).toBeInTheDocument();
});
it('should render with custom text', () => {
render(<Button>Submit</Button>);
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});
it('should render with different variants', () => {
const { rerender } = render(<Button variant="contained">Contained</Button>);
expect(screen.getByRole('button', { name: /contained/i })).toBeInTheDocument();
rerender(<Button variant="outlined">Outlined</Button>);
expect(screen.getByRole('button', { name: /outlined/i })).toBeInTheDocument();
rerender(<Button variant="text">Text</Button>);
expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
fireEvent.click(screen.getByRole('button', { name: /click me/i }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should not call onClick when disabled', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick} disabled>Disabled Button</Button>);
fireEvent.click(screen.getByRole('button', { name: /disabled button/i }));
expect(handleClick).not.toHaveBeenCalled();
});
});
describe('Styling', () => {
it('should apply fullWidth prop', () => {
render(<Button fullWidth>Full Width</Button>);
const button = screen.getByRole('button', { name: /full width/i });
expect(button).toHaveClass('MuiButton-fullWidth');
});
});
describe('States', () => {
it('should render in loading state', () => {
render(<Button loading>Loading</Button>);
const button = screen.getByRole('button', { name: /loading/i });
expect(button).toHaveClass('MuiButton-loading');
});
it('should render as disabled', () => {
render(<Button disabled>Disabled</Button>);
const button = screen.getByRole('button', { name: /disabled/i });
expect(button).toBeDisabled();
});
});
});
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { GlobalSnackbar, useSnackbar, type GlobalSnackbarProps } from '../GlobalSnackbar';
describe('GlobalSnackbar Component', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
const defaultProps: GlobalSnackbarProps = {
message: 'Test message',
open: true,
onClose: mockOnClose,
};
describe('Rendering', () => {
it('should render with default props', () => {
render(<GlobalSnackbar {...defaultProps} />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it.each(['success', 'info', 'warning', 'error'] as const)(
'should render with %s severity',
(severity) => {
render(
<GlobalSnackbar {...defaultProps} severity={severity} />
);
expect(screen.getByText('Test message')).toBeInTheDocument();
}
);
it('should render with custom anchor origin', () => {
render(
<GlobalSnackbar
{...defaultProps}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
/>
);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
});
describe('useSnackbar Hook', () => {
it('should return initial state', () => {
const TestComponent = () => {
const { snackbarProps } = useSnackbar();
return (
<div>
<span data-testid="open">{String(snackbarProps.open)}</span>
<span data-testid="message">{snackbarProps.message}</span>
</div>
);
};
render(<TestComponent />);
expect(screen.getByTestId('open').textContent).toBe('false');
expect(screen.getByTestId('message').textContent).toBe('');
});
it('should show message when showMessage is called', async () => {
const TestComponent = () => {
const { snackbarProps, showMessage } = useSnackbar();
return (
<div>
<button onClick={() => showMessage('Hello')}>Show</button>
<span data-testid="message">{snackbarProps.message}</span>
<span data-testid="open">{String(snackbarProps.open)}</span>
</div>
);
};
render(<TestComponent />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /show/i }));
});
expect(screen.getByTestId('message').textContent).toBe('Hello');
expect(screen.getByTestId('open').textContent).toBe('true');
});
it('should close message when closeMessage is called', async () => {
const TestComponent = () => {
const { snackbarProps, showMessage, closeMessage } = useSnackbar();
return (
<div>
<button onClick={() => showMessage('Hello')}>Show</button>
<button onClick={closeMessage}>Close</button>
<span data-testid="open">{String(snackbarProps.open)}</span>
</div>
);
};
render(<TestComponent />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /show/i }));
});
expect(screen.getByTestId('open').textContent).toBe('true');
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }));
});
expect(screen.getByTestId('open').textContent).toBe('false');
});
it('should apply custom options', async () => {
const TestComponent = () => {
const { snackbarProps, showMessage } = useSnackbar({ severity: 'warning' });
return (
<div>
<button onClick={() => showMessage('Warning!')}>Show</button>
<span data-testid="severity">{snackbarProps.severity}</span>
</div>
);
};
render(<TestComponent />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /show/i }));
});
expect(screen.getByTestId('severity').textContent).toBe('warning');
});
});
describe('Interaction', () => {
it('should call onClose when close is triggered', async () => {
render(<GlobalSnackbar {...defaultProps} />);
await act(async () => {
// Trigger close by timeout (autoHideDuration)
});
// Note: MUI Snackbar's close behavior depends on autoHideDuration
});
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import RouterContainer from '../RouterContainer';
import { RouterProvider } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage';
const mockRouterValue = {
currentPage: 'dashboard' as PageType,
visiblePages: ['dashboard', 'timestamp'] as PageType[],
pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
navigateLocal: vi.fn(),
syncNavigation: vi.fn(),
goBack: vi.fn(),
setVisiblePages: vi.fn(),
setPageOrder: vi.fn(),
};
vi.mock('@/providers/RouterProvider', () => ({
useRouter: () => mockRouterValue,
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
}));
describe('RouterContainer Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactElement) => {
return render(<RouterProvider>{ui}</RouterProvider>);
};
describe('Rendering', () => {
it('should render loading state when isLoaded is false', () => {
mockRouterValue.isLoaded = false;
renderWithProvider(<RouterContainer />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should render page content when isLoaded is true', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />);
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
});
});
describe('Animation classes', () => {
it('should apply dashboard animation class on dashboard page', () => {
mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<RouterContainer />);
const box = document.querySelector('.page-transition-dashboard');
expect(box).toBeInTheDocument();
});
it('should apply enter animation class on non-dashboard page', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<RouterContainer />);
const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument();
});
});
describe('Route handling', () => {
it('should update when currentPage changes', () => {
const { rerender } = renderWithProvider(<RouterContainer />);
mockRouterValue.currentPage = 'timestamp';
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>);
const box = document.querySelector('.page-transition-enter');
expect(box).toBeInTheDocument();
});
});
});
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
import type { StorageCleanerOptions } from '@/types/storage';
describe('StorageCleanerConfirm Component', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const defaultOptions: StorageCleanerOptions = {
localStorage: true,
sessionStorage: true,
indexedDB: true,
cookies: true,
cacheStorage: true,
serviceWorkers: true,
};
const renderComponent = (props?: Partial<React.ComponentProps<typeof StorageCleanerConfirm>>) => {
return render(
<StorageCleanerConfirm
open={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
options={defaultOptions}
{...props}
/>
);
};
describe('Rendering', () => {
it('should render dialog when open', () => {
renderComponent();
expect(screen.getByText('确认清理数据?')).toBeInTheDocument();
});
it('should display warning message', () => {
renderComponent();
expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument();
});
it('should display selected options as chips', () => {
renderComponent();
expect(screen.getByText('localStorage')).toBeInTheDocument();
expect(screen.getByText('sessionStorage')).toBeInTheDocument();
expect(screen.getByText('cookies')).toBeInTheDocument();
});
it('should display cancel and confirm buttons', () => {
renderComponent();
expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should call onClose when cancel is clicked', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /取消/i }));
expect(mockOnClose).toHaveBeenCalledTimes(1);
expect(mockOnConfirm).not.toHaveBeenCalled();
});
it('should call onConfirm when confirm is clicked', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /确认清理/i }));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
expect(mockOnClose).not.toHaveBeenCalled();
});
});
describe('Options filtering', () => {
it('should only show selected options', () => {
const partialOptions: StorageCleanerOptions = {
localStorage: true,
sessionStorage: false,
indexedDB: true,
cookies: false,
cacheStorage: false,
serviceWorkers: false,
};
renderComponent({ options: partialOptions });
expect(screen.getByText('localStorage')).toBeInTheDocument();
expect(screen.getByText('indexedDB')).toBeInTheDocument();
expect(screen.queryByText('sessionStorage')).not.toBeInTheDocument();
expect(screen.queryByText('cookies')).not.toBeInTheDocument();
});
it('should handle empty options', () => {
const emptyOptions: StorageCleanerOptions = {
localStorage: false,
sessionStorage: false,
indexedDB: false,
cookies: false,
cacheStorage: false,
serviceWorkers: false,
};
renderComponent({ options: emptyOptions });
const chips = screen.queryAllByRole('button');
expect(chips.length).toBeGreaterThanOrEqual(2);
});
});
describe('Dialog behavior', () => {
it('should not render when open is false', () => {
renderComponent({ open: false });
expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument();
});
it('should render with different options', () => {
const customOptions: StorageCleanerOptions = {
localStorage: false,
sessionStorage: true,
indexedDB: false,
cookies: true,
cacheStorage: false,
serviceWorkers: false,
};
renderComponent({ options: customOptions });
expect(screen.getByText('sessionStorage')).toBeInTheDocument();
expect(screen.getByText('cookies')).toBeInTheDocument();
});
});
});
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import ToolCard from '../ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
describe('ToolCard Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render with title and description', () => {
render(
<ToolCard
title="Test Tool"
description="This is a test tool"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>
);
expect(screen.getByText('Test Tool')).toBeInTheDocument();
expect(screen.getByText('This is a test tool')).toBeInTheDocument();
});
it('should render with only title when no description', () => {
render(
<ToolCard
title="Title Only"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>
);
expect(screen.getByText('Title Only')).toBeInTheDocument();
});
it('should render icon', () => {
render(
<ToolCard
title="With Icon"
colorCode="#2196f3"
icon={<AccessTimeIcon data-testid="test-icon" />}
onClick={() => {}}
/>
);
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
});
it('should render snapshot content when provided', () => {
render(
<ToolCard
title="With Snapshot"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
snapshot={<div data-testid="snapshot">Snapshot Content</div>}
/>
);
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
});
it('should not render snapshot section when not provided', () => {
const { container } = render(
<ToolCard
title="No Snapshot"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>
);
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
});
});
describe('AI Badge', () => {
it('should render AI badge when hasAI is true', () => {
render(
<ToolCard
title="AI Tool"
hasAI={true}
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>
);
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
expect(autoAwesomeIcon).toBeInTheDocument();
});
it('should not render AI badge when hasAI is false', () => {
render(
<ToolCard
title="Normal Tool"
hasAI={false}
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={() => {}}
/>
);
const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
expect(autoAwesomeIcon).not.toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should call onClick when clicked', () => {
const handleClick = vi.fn();
render(
<ToolCard
title="Clickable"
colorCode="#2196f3"
icon={<AccessTimeIcon />}
onClick={handleClick}
/>
);
const card = screen.getByText('Clickable').closest('.MuiBox-root');
if (card) {
fireEvent.click(card);
}
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
describe('Styling', () => {
it('should apply custom color code', () => {
const customColor = '#ff5722';
const { container } = render(
<ToolCard
title="Custom Color"
colorCode={customColor}
icon={<AccessTimeIcon />}
onClick={() => {}}
/>
);
const iconContainer = container.querySelector('.MuiBox-root > div');
expect(iconContainer).toBeInTheDocument();
});
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import TopBar from '../TopBar';
import { RouterProvider } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage';
const mockRouterValue = {
currentPage: 'dashboard' as PageType,
visiblePages: ['dashboard', 'timestamp'] as PageType[],
pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(),
navigateLocal: vi.fn(),
syncNavigation: vi.fn(),
goBack: vi.fn(),
setVisiblePages: vi.fn(),
setPageOrder: vi.fn(),
};
vi.mock('@/providers/RouterProvider', () => ({
useRouter: () => mockRouterValue,
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
}));
describe('TopBar Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactElement) => {
return render(<RouterProvider>{ui}</RouterProvider>);
};
describe('Rendering', () => {
it('should render with default title', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByText('Testing Tools')).toBeInTheDocument();
});
it('should render back button when not on dashboard', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
});
it('should not render back button on dashboard', () => {
mockRouterValue.currentPage = 'dashboard';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
});
it('should render settings button', () => {
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
});
});
describe('Interaction', () => {
it('should call onOpenOptions when settings button is clicked', () => {
const handleOpenOptions = vi.fn();
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
fireEvent.click(screen.getByTestId('SettingsIcon'));
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
});
it('should call goBack when back button is clicked', () => {
mockRouterValue.currentPage = 'timestamp';
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon'));
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
});
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest';
import {
ROUTES,
getRouteByKey,
getDefaultVisibleRoutes,
getAllRouteKeys,
getDefaultPageOrder,
} from '../routes';
describe('routes', () => {
describe('ROUTES', () => {
it('should have 6 routes defined', () => {
expect(ROUTES).toHaveLength(6);
});
it('should have all required properties for each route', () => {
ROUTES.forEach((route) => {
expect(route).toHaveProperty('key');
expect(route).toHaveProperty('label');
expect(route).toHaveProperty('defaultVisible');
expect(route).toHaveProperty('component');
expect(typeof route.key).toBe('string');
expect(typeof route.label).toBe('string');
expect(typeof route.defaultVisible).toBe('boolean');
expect(typeof route.component).toBe('function');
});
});
it('should have unique keys for each route', () => {
const keys = ROUTES.map((route) => route.key);
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
});
describe('getRouteByKey', () => {
it('should return dashboard route', () => {
const route = getRouteByKey('dashboard');
expect(route).toBeDefined();
expect(route?.key).toBe('dashboard');
expect(route?.label).toBe('Dashboard');
});
it('should return timestamp route', () => {
const route = getRouteByKey('timestamp');
expect(route).toBeDefined();
expect(route?.key).toBe('timestamp');
expect(route?.label).toBe('时间戳');
});
it('should return storageCleaner route', () => {
const route = getRouteByKey('storageCleaner');
expect(route).toBeDefined();
expect(route?.key).toBe('storageCleaner');
expect(route?.label).toBe('存储清理');
});
it('should return openUrl route', () => {
const route = getRouteByKey('openUrl');
expect(route).toBeDefined();
expect(route?.key).toBe('openUrl');
expect(route?.label).toBe('Open Url');
});
it('should return openUrlViewer route', () => {
const route = getRouteByKey('openUrlViewer');
expect(route).toBeDefined();
expect(route?.key).toBe('openUrlViewer');
expect(route?.label).toBe('查看');
});
it('should return undefined for invalid key', () => {
const route = getRouteByKey('invalid' as any);
expect(route).toBeUndefined();
});
});
describe('getDefaultVisibleRoutes', () => {
it('should return only visible routes', () => {
const visibleRoutes = getDefaultVisibleRoutes();
visibleRoutes.forEach((key) => {
const route = getRouteByKey(key);
expect(route?.defaultVisible).toBe(true);
});
});
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
const visibleRoutes = getDefaultVisibleRoutes();
expect(visibleRoutes).toContain('dashboard');
expect(visibleRoutes).toContain('timestamp');
expect(visibleRoutes).toContain('storageCleaner');
expect(visibleRoutes).toContain('openUrl');
});
it('should not include openUrlViewer (not visible by default)', () => {
const visibleRoutes = getDefaultVisibleRoutes();
expect(visibleRoutes).not.toContain('openUrlViewer');
});
});
describe('getAllRouteKeys', () => {
it('should return all route keys', () => {
const allKeys = getAllRouteKeys();
expect(allKeys).toHaveLength(6);
expect(allKeys).toContain('dashboard');
expect(allKeys).toContain('timestamp');
expect(allKeys).toContain('storageCleaner');
expect(allKeys).toContain('openUrl');
expect(allKeys).toContain('qrCode');
expect(allKeys).toContain('openUrlViewer');
});
});
describe('getDefaultPageOrder', () => {
it('should exclude dashboard from page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).not.toContain('dashboard');
});
it('should exclude openUrlViewer from page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).not.toContain('openUrlViewer');
});
it('should include timestamp, storageCleaner, openUrl, qrCode in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toContain('timestamp');
expect(pageOrder).toContain('storageCleaner');
expect(pageOrder).toContain('openUrl');
expect(pageOrder).toContain('qrCode');
});
it('should have 4 items in page order', () => {
const pageOrder = getDefaultPageOrder();
expect(pageOrder).toHaveLength(4);
});
});
});
+124
View File
@@ -0,0 +1,124 @@
import type { Theme } from '@mui/material';
import { alpha } from '@mui/material';
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
export type UnitType = 'ms' | 's';
export type ZoneType = (typeof ZONES)[number];
export const THEME_COLORS = {
primary: '#2196f3',
success: '#4caf50',
warning: '#ff9800',
error: '#f44336',
purple: '#9c27b0',
white: '#FFFFFF',
black: '#000000',
} as const;
export const timestampPageStyles = {
primaryColor: THEME_COLORS.primary,
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 3.5,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
'&.Mui-focused': {
bgcolor: '#fff',
borderColor: 'primary.main',
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.primary.main}1a`,
},
'&.Mui-error': {
borderColor: 'error.main',
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.error.main}1a`,
},
},
'& .MuiInputBase-input': {
py: 1.4,
px: 2,
fontSize: '0.9rem',
fontFamily: 'monospace',
fontWeight: 600,
},
},
cardBg: alpha(THEME_COLORS.primary, 0.04),
cardBorder: alpha(THEME_COLORS.primary, 0.1),
switcherBg: alpha(THEME_COLORS.primary, 0.08),
switcherBorder: alpha(THEME_COLORS.primary, 0.1),
mutedText: alpha(THEME_COLORS.primary, 0.4),
resultBg: alpha(THEME_COLORS.primary, 0.05),
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
} as const;
export const openUrlPageStyles = {
INPUT_STYLE: {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 3.5,
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': {
border: '1px solid',
borderColor: 'grey.100',
},
'&:hover fieldset': {
borderColor: 'grey.300',
},
'&:hover': { bgcolor: 'grey.50' },
'&.Mui-focused fieldset': {
borderColor: THEME_COLORS.purple,
},
'&.Mui-focused': {
bgcolor: '#fff',
boxShadow: (_theme: Theme) => `0 0 0 4px ${alpha(THEME_COLORS.purple, 0.1)}`,
},
},
'& .MuiInputBase-input': {
py: 1.2,
px: 2,
fontSize: '0.85rem',
fontWeight: 600,
},
'& .MuiInputLabel-root': {
fontSize: '0.85rem',
fontWeight: 700,
color: 'text.secondary',
'&.Mui-focused': { color: THEME_COLORS.purple },
},
},
themeColor: THEME_COLORS.purple,
themeBg: alpha(THEME_COLORS.purple, 0.1),
buttonBg: alpha(THEME_COLORS.purple, 0.85),
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.purple, 0.2)}`,
errorColor: THEME_COLORS.error,
errorBg: alpha(THEME_COLORS.error, 0.05),
} as const;
export const storageCleanerPageStyles = {
warningColor: THEME_COLORS.warning,
warningDark: '#f57c00',
warningBg: alpha(THEME_COLORS.warning, 0.05),
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
errorBg: alpha(THEME_COLORS.error, 0.05),
} as const;
export const qrCodePageStyles = {
primaryColor: THEME_COLORS.success,
primaryDark: '#388e3c',
successColor: THEME_COLORS.success,
successDark: '#388e3c',
white: THEME_COLORS.white,
black: THEME_COLORS.black,
} as const;
export const dashboardPageStyles = {
primaryColor: THEME_COLORS.primary,
backgroundColor: '#f5f5f5',
cardBackgroundColor: '#ffffff',
} as const;
+7
View File
@@ -4,6 +4,7 @@ import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage'; import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage'; import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage'; import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
export interface RouteConfig { export interface RouteConfig {
key: PageType; key: PageType;
@@ -37,6 +38,12 @@ export const ROUTES: RouteConfig[] = [
defaultVisible: true, defaultVisible: true,
component: OpenUrlPage, component: OpenUrlPage,
}, },
{
key: 'qrCode',
label: '二维码工具',
defaultVisible: true,
component: QrCodePage,
},
{ {
key: 'openUrlViewer', key: 'openUrlViewer',
label: '查看', label: '查看',
@@ -1,880 +0,0 @@
# 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
@@ -1,32 +0,0 @@
# 实施计划:修复 Popup 布局与滚动条样式
## 1. 目标
按照设计规范实施固定高度布局与极简滚动条,确保扩展弹窗显示稳定且美观。
## 2. 实施步骤
### 2.1 CSS 核心样式更新 (`entrypoints/popup/App.css`)
1. **视口锁定**: 更新 `html`, `body` 样式,固定 `width: 400px`, `height: 600px`
2. **根容器改造**:
-`.app` 修改为 Flex 容器:`display: flex; flex-direction: column; height: 100%; overflow: hidden;`
- 移除 `margin: 0 auto;``min-height: 100%;`
3. **滚动条变量与全局注入**:
-`:root` 中定义 `--sb-` 开头的滚动条样式变量。
- 使用 `*::-webkit-scrollbar` 系列伪元素定义全局极简滚动条。
### 2.2 React 结构重构 (`entrypoints/popup/App.tsx`)
1. **注入滚动容器**: 在 `nav-container` 之后,将所有的页面渲染(`TimestampPage`, `StorageCleanerPage`)包裹在一个统一的 `Box` 中。
2. **设置容器样式**: 给该 `Box` 设置 `sx={{ flex: 1, overflowY: 'auto', scrollbarGutter: 'stable' }}`
### 2.3 质量保障
1. **Lint 检测**: 运行 `npm run lint` 检查样式变量引用和 JSX 结构。
2. **类型检查**: 运行 `npm run compile` 验证 MUI 组件属性。
## 3. 验证计划
- 手动切换导航栏,观察窗口尺寸是否维持在 600px。
- 在“存储清理”页面展开所有选项,观察右侧是否出现极细滚动条,且不会挤压内容。
@@ -1,387 +0,0 @@
# 存储清理功能设计文档
## 概述
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 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. 测试用户偏好持久化
## 后续优化
- 显示清理前的存储使用情况
- 支持批量清理多个标签页
- 支持自定义域名清理
@@ -1,97 +0,0 @@
# CLAUDE.md Reorganization Design
**Date**: 2026-03-25
**Status**: Approved & Implemented
**Related Files**: `/CLAUDE.md`
## Overview
Reorganized the existing CLAUDE.md file to improve clarity, flow, and usability for future Claude Code instances working with this browser extension project.
## Problem Statement
The existing CLAUDE.md file contained comprehensive information but had organizational issues:
- Mixed development commands, architecture, and implementation details
- Redundant information in multiple sections
- Lack of clear logical flow from setup to development to reference
- Missing some technical details (path aliases, messaging system explanation)
## Design Goals
1. **Improve logical flow**: Structure content in order of developer needs
2. **Reduce redundancy**: Eliminate duplicate information
3. **Enhance readability**: Use clearer headings and organization
4. **Maintain completeness**: Preserve all essential information
5. **Add missing context**: Include path aliases and other technical specifics
## Solution Design
### Reorganized Structure
1. **Quick Start** - Essential commands and setup (first thing developers need)
2. **Architecture Overview** - Tech stack and high-level structure (context before diving in)
3. **Core Features** - What the extension does (timestamp conversion, storage cleaning)
4. **Development Workflow** - How to work with the codebase (browser compatibility, code quality tools)
5. **Configuration & Implementation** - Reference details (wxt.config.ts, manifest permissions)
6. **CI/CD & Project Context** - Background information (GitHub Actions, project history)
### Key Improvements
1. **Command Table**: Replaced bullet list with markdown table for better readability
2. **Simplified Directory Structure**: Removed excessive detail while maintaining clarity
3. **Logical Grouping**: Related information placed together (e.g., all storage cleaning details)
4. **Added Missing Information**: Path aliases (`@/`), TypeScript configuration highlights
5. **Clearer Section Titles**: More descriptive headings that indicate content purpose
### Content Preservation
All essential information from the original CLAUDE.md was preserved:
- All npm commands and their purposes
- Tech stack details
- Directory structure (simplified but complete)
- Core feature descriptions
- Storage cleaning implementation details
- Manifest permissions
- CI/CD workflow information
- Project history context
## Implementation Details
### File Changes
- **CLAUDE.md**: Complete rewrite with reorganized structure
- **No other files modified**: Only documentation changes
### Structural Changes
1. **Moved commands to front**: Developers need these immediately
2. **Grouped related topics**: All storage-related information together
3. **Separated workflow from reference**: Development process vs. configuration details
4. **Added visual hierarchy**: Clear section headings and subheadings
### Content Additions
1. **Path aliases section**: Explains `@/` import pattern
2. **TypeScript configuration highlights**: Key settings called out
3. **Better cross-references**: Links between related sections
## Validation
The reorganized CLAUDE.md was validated against:
- ✅ All original commands preserved
- ✅ All architectural information maintained
- ✅ All feature descriptions included
- ✅ All configuration details retained
- ✅ Improved readability and flow
- ✅ Added missing technical context
## Success Criteria
1. **Quick access to commands**: Developers can find essential npm scripts immediately
2. **Clear understanding of architecture**: Tech stack and structure explained upfront
3. **Logical information flow**: Follows natural developer workflow
4. **Complete reference**: All necessary information preserved and organized
5. **Improved usability**: Easier for Claude Code instances to understand and work with the project
## Future Considerations
1. **Regular updates**: CLAUDE.md should be updated when project structure changes
2. **User feedback**: Monitor if the reorganization improves developer experience
3. **Additional context**: Consider adding troubleshooting tips or common issues section if needed
@@ -1,48 +0,0 @@
# 设计规范:Popup 弹窗固定高度与极简滚动条
## 1. 目标
解决 Chrome 扩展弹窗高度“只增不减”的布局问题,提供稳定的 600px 固定高度体验,并实现符合极简主义设计规范的可复用滚动条样式。
## 2. 核心架构方案 (方案 A)
采用 **Flex 布局 + 视口锁定** 的策略,将弹窗尺寸固定在 `400px * 600px`
### 2.1 容器层级设计
- **`html`, `body`**: 锁定为 `400px * 600px`,并设置 `overflow: hidden` 防止出现双滚动条。
- **`.app` (根容器)**:
- 设置为 `display: flex; flex-direction: column;`
- 高度撑满父级 (`100%`)。
- 锁定 `overflow: hidden`
- **`nav-container` (导航栏)**:
- 位于顶部,高度固定,不参与 Flex 缩放。
- **内容展示区 (Page Container)**:
- 设置 `flex: 1`,自动填充剩余空间。
- 设置 `overflow-y: auto`,启用局部滚动。
- 设置 `scrollbar-gutter: stable`,预留滚动条空间,防止布局抖动。
## 3. 极简滚动条设计规范
为了确保在所有页面和组件中表现一致,采用全局 Webkit 伪元素定制方案。
### 3.1 变量定义
`:root` 中定义 CSS 变量以增强兼容性和可维护性:
- `--sb-width`: `6px` (极细)
- `--sb-thumb-color`: `rgba(0, 0, 0, 0.1)` (浅灰色半透明)
- `--sb-thumb-hover`: `rgba(0, 0, 0, 0.18)` (悬停时微深)
- `--sb-track-color`: `transparent` (背景完全透明)
### 3.2 样式表现
- **滑块 (Thumb)**: 胶囊状圆角 (`10px`)。
- **背景剪裁 (Background Clip)**: 使用 `content-box` 结合透明边框来实现滑块与边缘的微距感。
- **自动应用**: 使用通配符 `*::-webkit-scrollbar` 确保全局所有溢出容器均自动继承此样式。
## 4. 成功准则
- 切换“时间戳”和“存储清理”页面时,浏览器窗口大小保持 `600px` 不变。
- 当页面内容超过视口时,右侧显示细长、半透明的滚动条。
- 内容加载或 Accordion 展开时,页面水平方向不发生位移。
+4 -2
View File
@@ -56,7 +56,9 @@ export default defineBackground(() => {
); );
}); });
chrome.tabs.onUpdated.addListener((tabId) => { chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
console.log('加载完成的 Tab ID:', tabId); if (changeInfo.status === 'complete') {
console.log('加载完成的 Tab ID:', tabId);
}
}); });
}); });
+8 -31
View File
@@ -5,8 +5,6 @@ import {
Paper, Paper,
Switch, Switch,
Button, Button,
Snackbar,
Alert,
CircularProgress, CircularProgress,
Stack, Stack,
IconButton, IconButton,
@@ -16,14 +14,14 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { getRouteByKey, getDefaultPageOrder } from '@/config/routes'; import { getRouteByKey, getDefaultPageOrder, getDefaultVisibleRoutes } from '@/config/routes';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
function App() { function App() {
const [visiblePages, setVisiblePages] = useState<PageType[]>([]); const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
const [pageOrder, setPageOrder] = useState<PageType[]>([]); const [pageOrder, setPageOrder] = useState<PageType[]>([]);
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const [toast, setToast] = useState<string | null>(null); const { snackbarProps, showMessage } = useSnackbar();
const [toastSeverity, setToastSeverity] = useState<'success' | 'info' | 'warning'>('info');
useEffect(() => { useEffect(() => {
loadConfig(); loadConfig();
@@ -32,18 +30,14 @@ function App() {
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const [savedVisible, savedOrder] = await Promise.all([ const [savedVisible, savedOrder] = await Promise.all([
storageUtil.get('app/visiblePages', [ storageUtil.get('app/visiblePages', getDefaultVisibleRoutes()),
'timestamp',
'storageCleaner',
'openUrl',
] as PageType[]),
storageUtil.get('app/pageOrder', getDefaultPageOrder()), storageUtil.get('app/pageOrder', getDefaultPageOrder()),
]); ]);
setVisiblePages(savedVisible ?? ['timestamp', 'storageCleaner', 'openUrl']); setVisiblePages(savedVisible ?? getDefaultVisibleRoutes());
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder()); setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
} catch (error) { } catch (error) {
console.error('Failed to load config:', error); console.error('Failed to load config:', error);
setVisiblePages(['timestamp', 'storageCleaner', 'openUrl']); setVisiblePages(getDefaultVisibleRoutes());
setPageOrder(getDefaultPageOrder()); setPageOrder(getDefaultPageOrder());
} finally { } finally {
setIsLoaded(true); setIsLoaded(true);
@@ -113,12 +107,9 @@ function App() {
}; };
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => { const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
setToast(message); showMessage(message, { severity });
setToastSeverity(severity);
}; };
const handleCloseToast = () => setToast(null);
if (!isLoaded) { if (!isLoaded) {
return ( return (
<Box <Box
@@ -213,21 +204,7 @@ function App() {
</Box> </Box>
</Paper> </Paper>
<Snackbar <GlobalSnackbar {...snackbarProps} />
open={!!toast}
autoHideDuration={2000}
onClose={handleCloseToast}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
onClose={handleCloseToast}
severity={toastSeverity}
variant="filled"
sx={{ borderRadius: 2, fontWeight: 600 }}
>
{toast}
</Alert>
</Snackbar>
</Box> </Box>
); );
} }
+13 -1
View File
@@ -19,7 +19,7 @@
--sb-track-color: transparent; --sb-track-color: transparent;
} }
/* 强制固定视口尺寸 */ /* 默认 Popup 尺寸 */
html, html,
body, body,
#root { #root {
@@ -30,6 +30,18 @@ body,
overflow: hidden; /* 禁用外层滚动 */ overflow: hidden; /* 禁用外层滚动 */
} }
/* 独立窗口全屏自适应 */
@media screen and (min-width: 401px), screen and (min-height: 601px) {
html,
body,
#root {
width: 100vw;
height: 100vh;
min-width: 400px;
min-height: 600px;
}
}
/* 全局极简滚动条定制 */ /* 全局极简滚动条定制 */
*::-webkit-scrollbar { *::-webkit-scrollbar {
width: var(--sb-width); width: var(--sb-width);
+4 -1
View File
@@ -10,7 +10,10 @@ function App() {
return ( return (
<RouterProvider defaultRoute="dashboard" syncRoute={true} syncKey="app/popupRoute"> <RouterProvider defaultRoute="dashboard" syncRoute={true} syncKey="app/popupRoute">
<div className="app" style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> <div
className="app"
style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}
>
<TopBar onOpenOptions={handleOpenOptions} /> <TopBar onOpenOptions={handleOpenOptions} />
<RouterContainer /> <RouterContainer />
</div> </div>
+19 -2
View File
@@ -4,9 +4,11 @@ import ToolCard from '@/components/ToolCard';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import StorageIcon from '@mui/icons-material/Storage'; import StorageIcon from '@mui/icons-material/Storage';
import LanguageIcon from '@mui/icons-material/Language'; import LanguageIcon from '@mui/icons-material/Language';
import QrCodeIcon from '@mui/icons-material/QrCode';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import dayjs from '@/utils/dayjs'; import dayjs from '@/utils/dayjs';
import { dashboardPageStyles } from '@/config/pageTheme';
export default function DashboardPage() { export default function DashboardPage() {
const { navigateTo, visiblePages, pageOrder } = useRouter(); const { navigateTo, visiblePages, pageOrder } = useRouter();
@@ -30,13 +32,14 @@ export default function DashboardPage() {
colorCode="#2196f3" colorCode="#2196f3"
icon={<AccessTimeIcon sx={{ fontSize: 20 }} />} icon={<AccessTimeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('timestamp')} onClick={() => navigateTo('timestamp')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
snapshot={ snapshot={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography <Typography
sx={{ sx={{
fontFamily: 'monospace', fontFamily: 'monospace',
fontWeight: 600, fontWeight: 600,
color: '#2196f3', color: dashboardPageStyles.primaryColor,
fontSize: '0.85rem', fontSize: '0.85rem',
}} }}
> >
@@ -58,6 +61,7 @@ export default function DashboardPage() {
colorCode="#ff9800" colorCode="#ff9800"
icon={<StorageIcon sx={{ fontSize: 20 }} />} icon={<StorageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('storageCleaner')} onClick={() => navigateTo('storageCleaner')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/> />
); );
case 'openUrl': case 'openUrl':
@@ -69,6 +73,19 @@ export default function DashboardPage() {
colorCode="#9c27b0" colorCode="#9c27b0"
icon={<LanguageIcon sx={{ fontSize: 20 }} />} icon={<LanguageIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('openUrl')} onClick={() => navigateTo('openUrl')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/>
);
case 'qrCode':
return (
<ToolCard
key={key}
title="二维码工具"
description="URL 转二维码与二维码解析"
colorCode="#4caf50"
icon={<QrCodeIcon sx={{ fontSize: 20 }} />}
onClick={() => navigateTo('qrCode')}
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
/> />
); );
default: default:
@@ -77,7 +94,7 @@ export default function DashboardPage() {
}; };
return ( return (
<Box sx={{ bgcolor: 'grey.50', minHeight: '100%', pb: 4 }}> <Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 4 }}>
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}> <Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{pageOrder.map((key) => (isVisible(key) ? renderCard(key) : null))} {pageOrder.map((key) => (isVisible(key) ? renderCard(key) : null))}
+20 -42
View File
@@ -11,7 +11,6 @@ import {
Container, Container,
Stack, Stack,
alpha, alpha,
Theme,
Tooltip, Tooltip,
} from '@mui/material'; } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
@@ -23,40 +22,10 @@ import LinkIcon from '@mui/icons-material/Link';
import Button from '@/components/Button'; import Button from '@/components/Button';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { useRouter } from '@/providers/RouterProvider';
import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage'; import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage';
import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme';
const THEME_COLOR = '#9c27b0'; const THEME_COLOR = openUrlPageStyles.themeColor;
const INPUT_STYLE = {
'& .MuiOutlinedInput-root': {
bgcolor: 'background.paper',
borderRadius: 3.5,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
'&.Mui-focused': {
bgcolor: '#fff',
borderColor: THEME_COLOR,
boxShadow: (_theme: Theme) => `0 0 0 4px ${alpha(THEME_COLOR, 0.1)}`,
},
},
'& .MuiInputBase-input': {
py: 1.2,
px: 2,
fontSize: '0.85rem',
fontWeight: 600,
},
'& .MuiInputLabel-root': {
fontSize: '0.85rem',
fontWeight: 700,
color: 'text.secondary',
mb: 0.5,
'&.Mui-focused': { color: THEME_COLOR },
},
};
const DEFAULT_PREFERENCES: OpenUrlPreferences = { const DEFAULT_PREFERENCES: OpenUrlPreferences = {
entries: [], entries: [],
@@ -68,7 +37,6 @@ export default function OpenUrlPage() {
const [newUrl, setNewUrl] = useState<string>(''); const [newUrl, setNewUrl] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const { snackbarProps, showMessage } = useSnackbar(); const { snackbarProps, showMessage } = useSnackbar();
const { syncNavigation } = useRouter();
const showMixedContentWarning = const showMixedContentWarning =
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1'); newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
@@ -139,8 +107,10 @@ export default function OpenUrlPage() {
const handleOpenInSidebar = async (entry: OpenUrlEntry) => { const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
try { try {
// 存储目标 URL
await storageUtil.set('openUrl/currentUrl', entry.url); await storageUtil.set('openUrl/currentUrl', entry.url);
syncNavigation('openUrlViewer'); // 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
const [currentTab] = await chrome.tabs.query({ const [currentTab] = await chrome.tabs.query({
active: true, active: true,
@@ -175,7 +145,7 @@ export default function OpenUrlPage() {
}; };
return ( return (
<Box sx={{ pb: 3 }}> <Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}> <Container sx={{ py: 2 }}>
{/* Header */} {/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}> <Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
@@ -196,10 +166,10 @@ export default function OpenUrlPage() {
fontWeight={900} fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }} sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
> >
URL URL
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
URL
</Typography> </Typography>
</Box> </Box>
</Stack> </Stack>
@@ -224,8 +194,12 @@ export default function OpenUrlPage() {
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
fullWidth fullWidth
variant="outlined" variant="outlined"
sx={INPUT_STYLE} sx={openUrlPageStyles.INPUT_STYLE}
InputLabelProps={{ shrink: true }} slotProps={{
inputLabel: {
shrink: true,
},
}}
/> />
<TextField <TextField
label="目标 URL" label="目标 URL"
@@ -234,8 +208,12 @@ export default function OpenUrlPage() {
onChange={(e) => setNewUrl(e.target.value)} onChange={(e) => setNewUrl(e.target.value)}
fullWidth fullWidth
variant="outlined" variant="outlined"
sx={INPUT_STYLE} sx={openUrlPageStyles.INPUT_STYLE}
InputLabelProps={{ shrink: true }} slotProps={{
inputLabel: {
shrink: true,
},
}}
/> />
{showMixedContentWarning && ( {showMixedContentWarning && (
+94 -27
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Box, Typography } from '@mui/material'; import { Box, Typography, CircularProgress, Alert } from '@mui/material';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { dashboardPageStyles } from '@/config/pageTheme';
// 只允许 HTTP/HTTPS 协议,阻止危险协议 // 只允许 HTTP/HTTPS 协议,阻止危险协议
const ALLOWED_PROTOCOLS = ['http:', 'https:']; const ALLOWED_PROTOCOLS = ['http:', 'https:'];
@@ -8,6 +9,7 @@ const ALLOWED_PROTOCOLS = ['http:', 'https:'];
export default function OpenUrlViewerPage() { export default function OpenUrlViewerPage() {
const [currentUrl, setCurrentUrl] = useState<string>(''); const [currentUrl, setCurrentUrl] = useState<string>('');
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const [iframeLoading, setIframeLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 验证 URL 是否安全 // 验证 URL 是否安全
@@ -24,33 +26,59 @@ export default function OpenUrlViewerPage() {
}; };
// 从存储加载当前选中的 URL // 从存储加载当前选中的 URL
useEffect(() => { const loadCurrentUrl = useCallback(async () => {
const loadCurrentUrl = async () => { try {
try { const saved = await storageUtil.get('openUrl/currentUrl', '');
const saved = await storageUtil.get('openUrl/currentUrl', ''); if (saved) {
if (saved) { const validationError = validateUrl(saved);
const validationError = validateUrl(saved); if (validationError) {
if (validationError) { setError(validationError);
setError(validationError); } else {
} else { setCurrentUrl(saved);
setCurrentUrl(saved); setError(null);
setError(null); setIframeLoading(true); // 重置 iframe 加载状态
}
} }
} catch (error) { } else {
console.error('Failed to load current URL:', error); setError(null);
setError('加载 URL 失败'); setCurrentUrl('');
} finally { }
setIsLoaded(true); } catch (error) {
console.error('Failed to load current URL:', error);
setError('加载 URL 失败');
} finally {
setIsLoaded(true);
}
}, []);
// 初始加载
useEffect(() => {
loadCurrentUrl();
}, [loadCurrentUrl]);
// 监听存储变化,确保 URL 变更时能及时更新
useEffect(() => {
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes['openUrl/currentUrl']) {
loadCurrentUrl();
} }
}; };
loadCurrentUrl();
}, []); chrome.storage.onChanged.addListener(handleStorageChange);
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
}, [loadCurrentUrl]);
if (!isLoaded) { if (!isLoaded) {
return ( return (
<Box sx={{ p: 2, flex: 1 }}> <Box
<Typography color="text.secondary">Loading...</Typography> sx={{
p: 2,
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<CircularProgress size={40} />
</Box> </Box>
); );
} }
@@ -58,7 +86,10 @@ export default function OpenUrlViewerPage() {
if (error) { if (error) {
return ( return (
<Box sx={{ p: 2, flex: 1 }}> <Box sx={{ p: 2, flex: 1 }}>
<Typography color="error">{error}</Typography> <Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
<Typography color="text.secondary"> OpenUrl URL</Typography>
</Box> </Box>
); );
} }
@@ -66,15 +97,46 @@ export default function OpenUrlViewerPage() {
if (!currentUrl) { if (!currentUrl) {
return ( return (
<Box sx={{ p: 2, flex: 1 }}> <Box sx={{ p: 2, flex: 1 }}>
<Typography color="text.secondary"> <Alert severity="info" sx={{ mb: 2 }}>
URL OpenUrl URL URL
</Typography> </Alert>
<Typography color="text.secondary"> OpenUrl URL </Typography>
</Box> </Box>
); );
} }
return ( return (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}> <Box
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
bgcolor: dashboardPageStyles.backgroundColor,
}}
>
{/* 加载状态指示器 */}
{iframeLoading && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
bgcolor: 'rgba(255, 255, 255, 0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
}}
>
<Box sx={{ textAlign: 'center' }}>
<CircularProgress size={60} />
<Typography sx={{ mt: 2 }}>...</Typography>
</Box>
</Box>
)}
<iframe <iframe
src={currentUrl} src={currentUrl}
title="OpenUrl Viewer" title="OpenUrl Viewer"
@@ -85,6 +147,11 @@ export default function OpenUrlViewerPage() {
border: 'none', border: 'none',
display: 'block', display: 'block',
}} }}
onLoad={() => setIframeLoading(false)}
onError={() => {
setIframeLoading(false);
setError('URL 加载失败,请检查网络连接或 URL 是否正确');
}}
/> />
</Box> </Box>
); );
+560
View File
@@ -0,0 +1,560 @@
import { useState, useRef, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Button,
Stack,
Alert,
InputAdornment,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import { Container, alpha } from '@mui/system';
import QrCodeIcon from '@mui/icons-material/QrCode';
import ImageIcon from '@mui/icons-material/Image';
import LinkIcon from '@mui/icons-material/Link';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import qrcode from 'qrcode';
import jsQR from 'jsqr';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import CopyButton from '@/components/CopyButton';
import { storageUtil } from '@/utils/chromeStorage';
import { qrCodePageStyles, dashboardPageStyles } from '@/config/pageTheme';
const QrCodePage = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const [isInitialized, setIsInitialized] = useState(false);
// URL 转二维码状态
const [urlInput, setUrlInput] = useState('');
const [urlError, setUrlError] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [generating, setGenerating] = useState(false);
// 二维码转 URL 状态
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
const [parsedUrl, setParsedUrl] = useState('');
const [parseError, setParseError] = useState('');
const [parsing, setParsing] = useState(false);
// 卡片展开状态
const [urlExpanded, setUrlExpanded] = useState(true);
const [qrExpanded, setQrExpanded] = useState(false);
// 引用
const qrCodeRef = useRef<HTMLDivElement>(null);
// 从存储加载状态
useEffect(() => {
const loadState = async () => {
try {
const savedUrlExpanded = await storageUtil.get('qrCode/urlExpanded', true);
const savedQrExpanded = await storageUtil.get('qrCode/qrExpanded', false);
setUrlExpanded(savedUrlExpanded ?? true);
setQrExpanded(savedQrExpanded ?? false);
} catch (error) {
console.error('加载状态失败:', error);
} finally {
setIsInitialized(true);
}
};
loadState();
}, []);
// 保存状态到存储(仅在初始化完成后保存)
useEffect(() => {
if (!isInitialized) return;
const saveState = async () => {
try {
await storageUtil.set('qrCode/urlExpanded', urlExpanded);
await storageUtil.set('qrCode/qrExpanded', qrExpanded);
} catch (error) {
console.error('保存状态失败:', error);
}
};
saveState();
}, [urlExpanded, qrExpanded, isInitialized]);
// 初始化未完成时显示加载状态
if (!isInitialized) {
return (
<Container
sx={{
py: 4,
maxWidth: 400,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 200,
}}
>
<CircularProgress />
</Container>
);
}
// 处理 URL 输入变化
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUrlInput(e.target.value);
setUrlError('');
};
// 处理文件选择
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// 只有当用户实际选择了文件时才更新状态
// 如果用户取消选择,保持原有状态不变
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
setQrCodeFile(file);
setParseError('');
setParsedUrl('');
}
};
// 生成二维码
const generateQrCode = async () => {
if (!urlInput) {
setUrlError('请输入 URL');
return;
}
try {
setGenerating(true);
setUrlError('');
// 验证 URL 格式
let url = urlInput;
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
// 生成二维码
const dataUrl = await qrcode.toDataURL(url, {
width: 200,
margin: 2,
color: {
dark: qrCodePageStyles.black,
light: qrCodePageStyles.white,
},
});
setQrCodeDataUrl(dataUrl);
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('生成二维码失败:', error);
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
} finally {
setGenerating(false);
}
};
// 解析二维码
const parseQrCode = async () => {
if (!qrCodeFile) {
showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
return;
}
try {
setParsing(true);
setParseError('');
setParsedUrl('');
// 读取文件并解析
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('无法创建 canvas 上下文');
}
const image = new Image();
image.src = URL.createObjectURL(qrCodeFile);
await new Promise<void>((resolve, reject) => {
image.onload = () => {
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
resolve();
};
image.onerror = () => reject(new Error('图片加载失败'));
});
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
setParsedUrl(code.data);
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
} else {
showMessage('未检测到二维码', { severity: 'error', autoHideDuration: 1000 });
}
} catch (error) {
console.error('解析二维码失败:', error);
showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
} finally {
setParsing(false);
}
};
// 下载二维码
const downloadQrCode = () => {
if (!qrCodeDataUrl) return;
const link = document.createElement('a');
link.href = qrCodeDataUrl;
link.download = 'qrcode.png';
link.click();
showMessage('二维码下载成功', { severity: 'success', autoHideDuration: 300 });
};
// 复制二维码到剪贴板
const copyQrCode = async () => {
if (!qrCodeDataUrl) return;
try {
// 将 data URL 转换为 Blob
const response = await fetch(qrCodeDataUrl);
const blob = await response.blob();
// 使用 Clipboard API 写入图像
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
showMessage('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
} catch (error) {
console.error('复制二维码失败:', error);
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
}
};
return (
<Box sx={{ bgcolor: dashboardPageStyles.backgroundColor, minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2, maxWidth: 400 }}>
{/* Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha(qrCodePageStyles.successColor, 0.1),
color: qrCodePageStyles.successColor,
display: 'flex',
}}
>
<QrCodeIcon sx={{ fontSize: 20 }} />
</Box>
<Box sx={{ flex: 1 }}>
<Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Stack>
<Stack spacing={3}>
{/* URL 转二维码 */}
<Accordion
expanded={urlExpanded}
onChange={(_, isExpanded) => setUrlExpanded(isExpanded)}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<QrCodeIcon color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
URL
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<TextField
label="输入 URL"
placeholder="https://example.com"
value={urlInput}
onChange={handleUrlInputChange}
fullWidth
variant="outlined"
error={!!urlError}
helperText={urlError}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<Button
variant="contained"
startIcon={
generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />
}
onClick={generateQrCode}
disabled={generating}
sx={{
py: 1.2,
borderRadius: 3,
bgcolor: qrCodePageStyles.successColor,
fontWeight: 700,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
{generating ? '生成中...' : '生成二维码'}
</Button>
{/* 二维码显示区域 */}
<Box
ref={qrCodeRef}
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: 'grey.200',
borderRadius: 3,
p: 2,
bgcolor: 'grey.50',
}}
>
{qrCodeDataUrl ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
width: '100%',
}}
>
<img
src={qrCodeDataUrl}
alt="QR Code"
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
/>
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={downloadQrCode}
sx={{
borderRadius: 2,
borderColor: qrCodePageStyles.successColor,
color: qrCodePageStyles.successColor,
'&:hover': {
borderColor: qrCodePageStyles.successDark,
bgcolor: alpha(qrCodePageStyles.successColor, 0.05),
},
}}
>
</Button>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={copyQrCode}
sx={{
borderRadius: 2,
bgcolor: qrCodePageStyles.successColor,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
</Button>
</Box>
</Box>
) : (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
</Typography>
)}
</Box>
</Stack>
</AccordionDetails>
</Accordion>
{/* 二维码转 URL */}
<Accordion
expanded={qrExpanded}
onChange={(_, isExpanded) => setQrExpanded(isExpanded)}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:before': { display: 'none' },
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<LinkIcon color="success" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
URL
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Stack spacing={3}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
border: '2px dashed',
borderColor: qrCodeFile ? qrCodePageStyles.successColor : 'grey.200',
borderRadius: 3,
p: 4,
bgcolor: qrCodeFile ? alpha(qrCodePageStyles.successColor, 0.05) : 'grey.50',
cursor: 'pointer',
transition: 'all 0.2s',
'&:hover': {
borderColor: qrCodePageStyles.successColor,
bgcolor: alpha(qrCodePageStyles.successColor, 0.05),
},
}}
>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
style={{
display: 'none',
}}
id="qr-code-upload"
/>
<label
htmlFor="qr-code-upload"
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
>
{qrCodeFile ? (
<Box sx={{ textAlign: 'center', width: '100%' }}>
<img
src={URL.createObjectURL(qrCodeFile)}
alt="QR Code Preview"
style={{
maxWidth: '100%',
maxHeight: 160,
borderRadius: 8,
objectFit: 'contain',
}}
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{qrCodeFile.name}
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
PNGJPGWEBP
</Typography>
</>
)}
</label>
</Box>
<Button
variant="contained"
startIcon={
parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />
}
onClick={parseQrCode}
disabled={parsing}
sx={{
py: 1.2,
borderRadius: 3,
bgcolor: qrCodePageStyles.successColor,
fontWeight: 700,
'&:hover': {
bgcolor: qrCodePageStyles.successDark,
},
}}
>
{parsing ? '解析中...' : '解析二维码'}
</Button>
{/* 解析结果显示 */}
<Box
sx={{
position: 'relative',
mt: 2,
}}
>
<TextField
label="解析结果"
value={parsedUrl}
fullWidth
variant="outlined"
slotProps={{
input: {
readOnly: true,
endAdornment: (
<InputAdornment position="end">
<CopyButton
text={parsedUrl}
tooltip="复制"
size="small"
color={qrCodePageStyles.primaryColor}
showMessage={showMessage}
/>
</InputAdornment>
),
},
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
</Box>
{parseError && (
<Alert severity="error" sx={{ borderRadius: 3 }}>
{parseError}
</Alert>
)}
</Stack>
</AccordionDetails>
</Accordion>
</Stack>
<GlobalSnackbar {...snackbarProps} />
</Container>
</Box>
);
};
export default QrCodePage;
+298 -107
View File
@@ -9,6 +9,7 @@ import {
Stack, Stack,
Switch, Switch,
Grid, Grid,
CircularProgress,
} from '@mui/material'; } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
import StorageIcon from '@mui/icons-material/Storage'; import StorageIcon from '@mui/icons-material/Storage';
@@ -34,6 +35,7 @@ import {
getServiceWorkerCount, getServiceWorkerCount,
formatSize, formatSize,
} from '@/utils/storageCleaner'; } from '@/utils/storageCleaner';
import { storageCleanerPageStyles } from '@/config/pageTheme';
const DEFAULT_OPTIONS: StorageCleanerOptions = { const DEFAULT_OPTIONS: StorageCleanerOptions = {
localStorage: true, localStorage: true,
@@ -52,6 +54,7 @@ const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
export default function StorageCleanerPage() { export default function StorageCleanerPage() {
const [domain, setDomain] = useState<string>(''); const [domain, setDomain] = useState<string>('');
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [isInitializing, setIsInitializing] = useState<boolean>(true);
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS); const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
const [sizes, setSizes] = useState<Record<string, number>>({}); const [sizes, setSizes] = useState<Record<string, number>>({});
const [autoRefresh, setAutoRefresh] = useState<boolean>(true); const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
@@ -60,51 +63,84 @@ export default function StorageCleanerPage() {
const [showConfirm, setShowConfirm] = useState<boolean>(false); const [showConfirm, setShowConfirm] = useState<boolean>(false);
const { snackbarProps, showMessage } = useSnackbar(); const { snackbarProps, showMessage } = useSnackbar();
const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (reloadTimeoutRef.current) clearTimeout(reloadTimeoutRef.current); if (reloadTimeoutRef.current) clearTimeout(reloadTimeoutRef.current);
if (resultTimeoutRef.current) clearTimeout(resultTimeoutRef.current);
}; };
}, []); }, []);
const loadInfo = async () => { const loadInfo = useCallback(async () => {
const tab = await getCurrentTab(); try {
if (!tab || !tab.url) { const tab = await getCurrentTab();
setError('无法获取当前标签页'); if (!tab || !tab.url) {
return; setError('无法获取当前标签页');
} return;
if (isRestrictedUrl(tab.url)) { }
setError('存储清理功能不支持此页面'); if (isRestrictedUrl(tab.url)) {
return; setError('存储清理功能不支持此页面');
} return;
const url = tab.url; }
const tabId = tab.id!;
setDomain(new URL(url).hostname);
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([ // 重置错误状态
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES), setError('');
getCookieSize(url),
getLocalStorageSize(tabId),
getSessionStorageSize(tabId),
getIndexedDBSize(tabId),
getCacheStorageSize(tabId),
getServiceWorkerCount(tabId),
]);
setAutoRefresh(savedPrefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh); const url = tab.url;
setOptions(savedPrefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes); const tabId = tab.id!;
setSizes({ setDomain(new URL(url).hostname);
cookies: cSize,
localStorage: lsSize, const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
sessionStorage: ssSize, storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
indexedDB: idbSize, getCookieSize(url),
cacheStorage: cacheCount, getLocalStorageSize(tabId),
serviceWorkers: swCount, getSessionStorageSize(tabId),
}); getIndexedDBSize(tabId),
}; getCacheStorageSize(tabId),
getServiceWorkerCount(tabId),
]);
if (savedPrefs) {
setAutoRefresh(savedPrefs.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
}
setSizes({
cookies: cSize,
localStorage: lsSize,
sessionStorage: ssSize,
indexedDB: idbSize,
cacheStorage: cacheCount,
serviceWorkers: swCount,
});
} finally {
setIsInitializing(false);
}
}, []);
const loadInfoRef = useRef(loadInfo);
loadInfoRef.current = loadInfo;
useEffect(() => { useEffect(() => {
loadInfo(); loadInfoRef.current();
const handleTabChange = () => loadInfoRef.current();
const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
if (changeInfo.status === 'complete' || changeInfo.url) {
loadInfoRef.current();
}
};
chrome.tabs.onActivated.addListener(handleTabChange);
chrome.tabs.onUpdated.addListener(handleTabUpdated);
chrome.windows.onFocusChanged.addListener(handleTabChange);
return () => {
chrome.tabs.onActivated.removeListener(handleTabChange);
chrome.tabs.onUpdated.removeListener(handleTabUpdated);
chrome.windows.onFocusChanged.removeListener(handleTabChange);
};
}, []); }, []);
const handleAutoRefreshChange = useCallback( const handleAutoRefreshChange = useCallback(
@@ -164,6 +200,13 @@ export default function StorageCleanerPage() {
try { try {
const cleaningResult = await clearStorage(tab.id, tab.url, options); const cleaningResult = await clearStorage(tab.id, tab.url, options);
setResult(cleaningResult); setResult(cleaningResult);
// 5秒后自动清除结果提示
if (resultTimeoutRef.current) clearTimeout(resultTimeoutRef.current);
resultTimeoutRef.current = setTimeout(() => {
setResult(null);
}, 5000);
if (autoRefresh && cleaningResult.success && tab.id !== undefined) { if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
showMessage('清理成功,即将刷新页面'); showMessage('清理成功,即将刷新页面');
reloadTimeoutRef.current = setTimeout(() => { reloadTimeoutRef.current = setTimeout(() => {
@@ -178,20 +221,78 @@ export default function StorageCleanerPage() {
setLoading(false); setLoading(false);
setShowConfirm(false); setShowConfirm(false);
} }
}, [options, autoRefresh, showMessage]); }, [options, autoRefresh, showMessage, loadInfo]);
if (isInitializing) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={24} color="warning" />
</Box>
);
}
if (error) { if (error) {
return ( return (
<Container sx={{ py: 4 }}> <Container
<Alert severity="error" icon={<WarningIcon />} sx={{ borderRadius: 3 }}> sx={{
{error} py: 8,
</Alert> display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '400px',
textAlign: 'center',
}}
>
<Box sx={{ width: '100%', maxWidth: 320 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 4,
p: 4,
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
border: '1px solid rgba(244, 67, 54, 0.2)',
bgcolor: 'rgba(244, 67, 54, 0.05)',
}}
>
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
<Typography
variant="body1"
color="error.main"
sx={{
fontSize: '0.9rem',
fontWeight: 700,
lineHeight: 1.4,
mb: 3,
}}
>
{error}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{
fontSize: '0.75rem',
fontWeight: 500,
lineHeight: 1.4,
}}
>
</Typography>
</Box>
</Box>
</Container> </Container>
); );
} }
// 这里的总大小仅包含以字节计算的项 // 这里的总大小仅包含以字节计算的项
const totalSize = (sizes.cookies || 0) + (sizes.localStorage || 0) + (sizes.sessionStorage || 0) + (sizes.indexedDB || 0); const totalSize =
(sizes.cookies || 0) +
(sizes.localStorage || 0) +
(sizes.sessionStorage || 0) +
(sizes.indexedDB || 0);
const OptionItem = ({ const OptionItem = ({
label, label,
@@ -211,25 +312,32 @@ export default function StorageCleanerPage() {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
py: 0.8, py: 1,
px: 1.2, px: 1.5,
borderRadius: 2.5, borderRadius: 3,
transition: 'all 0.2s', transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': { bgcolor: 'grey.50' }, bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
'&:hover': {
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
},
}} }}
> >
<Box sx={{ flex: 1, minWidth: 0, mr: 1 }}> <Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
<Typography <Typography
variant="caption" variant="body2"
fontWeight={800} fontWeight={700}
color="text.primary" color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
sx={{ sx={{
fontSize: '0.7rem', fontSize: '0.75rem',
display: 'block', display: 'block',
lineHeight: 1.1, lineHeight: 1.2,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis' textOverflow: 'ellipsis',
transition: 'color 0.2s',
}} }}
> >
{label} {label}
@@ -238,13 +346,14 @@ export default function StorageCleanerPage() {
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: 'text.disabled', color: 'text.secondary',
fontSize: '0.6rem', fontSize: '0.65rem',
fontWeight: 700, fontWeight: 600,
display: 'block', display: 'block',
mt: 0.2, mt: 0.3,
lineHeight: 1, lineHeight: 1,
whiteSpace: 'nowrap' whiteSpace: 'nowrap',
opacity: 0.8,
}} }}
> >
{isCount ? `${size}` : formatSize(size)} {isCount ? `${size}` : formatSize(size)}
@@ -253,12 +362,13 @@ export default function StorageCleanerPage() {
<Typography <Typography
variant="caption" variant="caption"
sx={{ sx={{
color: 'grey.300', color: 'grey.400',
fontSize: '0.6rem', fontSize: '0.65rem',
fontWeight: 500, fontWeight: 500,
display: 'block', display: 'block',
mt: 0.2, mt: 0.3,
lineHeight: 1 lineHeight: 1,
fontStyle: 'italic',
}} }}
> >
@@ -270,63 +380,89 @@ export default function StorageCleanerPage() {
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
color="warning" color="warning"
sx={{ p: 0.5 }} sx={{
p: 0.6,
'& .MuiSvgIcon-root': {
fontSize: 18,
transition: 'transform 0.2s',
},
'&:hover .MuiSvgIcon-root': {
transform: 'scale(1.1)',
},
}}
/> />
</Box> </Box>
); );
return ( return (
<Box sx={{ pb: 2 }}> <Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 2 }}>
<Container sx={{ py: 2 }}> <Container sx={{ py: 2 }}>
{/* Domain Header */} {/* Domain Header */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2 }}> <Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 3 }}>
<Box <Box
sx={{ sx={{
p: 1, p: 1.2,
borderRadius: 2.5, borderRadius: 3,
bgcolor: '#fff4e5', bgcolor: 'rgba(255, 152, 0, 0.1)',
color: '#ff9800', color: storageCleanerPageStyles.warningColor,
display: 'flex', display: 'flex',
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 152, 0, 0.15)',
transform: 'scale(1.05)',
},
}} }}
> >
<StorageIcon sx={{ fontSize: 20 }} /> <StorageIcon sx={{ fontSize: 22 }} />
</Box> </Box>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center"> <Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography <Typography
variant="subtitle1" variant="h6"
fontWeight={900} fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }} sx={{
letterSpacing: '-0.5px',
lineHeight: 1.2,
fontSize: '1rem',
color: 'text.primary',
}}
> >
</Typography> </Typography>
{totalSize > 0 && ( {totalSize > 0 && (
<Typography <Box
variant="caption"
sx={{ sx={{
bgcolor: '#fff4e5', bgcolor: 'rgba(255, 152, 0, 0.15)',
color: '#ff9800', color: storageCleanerPageStyles.warningColor,
px: 1, px: 1.5,
py: 0.2, py: 0.3,
borderRadius: 1.5, borderRadius: 2,
fontWeight: 800, fontWeight: 800,
fontSize: '0.65rem', fontSize: '0.7rem',
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(255, 152, 0, 0.25)',
},
}} }}
> >
{formatSize(totalSize)} {formatSize(totalSize)}
</Typography> </Box>
)} )}
</Stack> </Stack>
<Typography <Typography
variant="caption" variant="body2"
color="text.secondary" color="text.secondary"
sx={{ sx={{
fontWeight: 600, fontWeight: 600,
display: 'block', display: 'block',
maxWidth: 220, maxWidth: 240,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
mt: 0.3,
fontSize: '0.75rem',
}} }}
> >
{domain || '加载中...'} {domain || '加载中...'}
@@ -337,15 +473,20 @@ export default function StorageCleanerPage() {
{/* Storage Options Grid */} {/* Storage Options Grid */}
<Box <Box
sx={{ sx={{
mb: 2, mb: 3,
border: '1px solid', border: '1px solid',
borderColor: 'grey.100', borderColor: 'grey.100',
borderRadius: 4, borderRadius: 4,
p: 0.8, p: 1.2,
bgcolor: 'background.paper', bgcolor: 'background.paper',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
},
}} }}
> >
<Grid container spacing={0}> <Grid container spacing={1.5}>
<Grid size={6}> <Grid size={6}>
<OptionItem <OptionItem
label="LocalStorage" label="LocalStorage"
@@ -356,7 +497,7 @@ export default function StorageCleanerPage() {
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<OptionItem <OptionItem
label="Session" label="Session Storage"
checked={options.sessionStorage} checked={options.sessionStorage}
size={sizes.sessionStorage} size={sizes.sessionStorage}
onChange={() => handleOptionChange('sessionStorage')} onChange={() => handleOptionChange('sessionStorage')}
@@ -380,7 +521,7 @@ export default function StorageCleanerPage() {
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<OptionItem <OptionItem
label="Cache" label="Cache Storage"
checked={options.cacheStorage} checked={options.cacheStorage}
size={sizes.cacheStorage} size={sizes.cacheStorage}
isCount isCount
@@ -389,28 +530,34 @@ export default function StorageCleanerPage() {
</Grid> </Grid>
<Grid size={6}> <Grid size={6}>
<OptionItem <OptionItem
label="Workers" label="Service Workers"
checked={options.serviceWorkers} checked={options.serviceWorkers}
size={sizes.serviceWorkers} size={sizes.serviceWorkers}
isCount isCount
onChange={() => handleOptionChange('serviceWorkers')} onChange={() => handleOptionChange('serviceWorkers')}
/> />
</Grid> </Grid>
</Grid> </Grid>
<Divider sx={{ my: 0.8, borderColor: 'grey.50' }} /> <Divider sx={{ my: 1.2, borderColor: 'grey.100' }} />
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
px: 1.2, px: 1.5,
py: 0.4, py: 0.6,
bgcolor: 'rgba(0, 0, 0, 0.02)',
borderRadius: 2,
transition: 'all 0.2s',
'&:hover': {
bgcolor: 'rgba(0, 0, 0, 0.04)',
},
}} }}
> >
<Typography <Typography
variant="caption" variant="body2"
fontWeight={800} fontWeight={700}
sx={{ color: 'text.secondary', fontSize: '0.65rem' }} sx={{ color: 'text.secondary', fontSize: '0.7rem' }}
> >
</Typography> </Typography>
@@ -420,7 +567,16 @@ export default function StorageCleanerPage() {
indeterminate={someSelected} indeterminate={someSelected}
onChange={(e) => handleSelectAll(e.target.checked)} onChange={(e) => handleSelectAll(e.target.checked)}
color="warning" color="warning"
sx={{ p: 0.5 }} sx={{
p: 0.6,
'& .MuiSvgIcon-root': {
fontSize: 18,
transition: 'transform 0.2s',
},
'&:hover .MuiSvgIcon-root': {
transform: 'scale(1.1)',
},
}}
/> />
</Box> </Box>
</Box> </Box>
@@ -428,8 +584,8 @@ export default function StorageCleanerPage() {
{/* Auto Refresh Toggle */} {/* Auto Refresh Toggle */}
<Box <Box
sx={{ sx={{
mb: 2, mb: 3,
p: 1.2, p: 1.5,
borderRadius: 4, borderRadius: 4,
bgcolor: 'background.paper', bgcolor: 'background.paper',
border: '1px solid', border: '1px solid',
@@ -437,9 +593,14 @@ export default function StorageCleanerPage() {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
},
}} }}
> >
<Typography variant="caption" fontWeight={700}> <Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem' }}>
</Typography> </Typography>
<Switch <Switch
@@ -447,6 +608,18 @@ export default function StorageCleanerPage() {
checked={autoRefresh} checked={autoRefresh}
onChange={(e) => handleAutoRefreshChange(e.target.checked)} onChange={(e) => handleAutoRefreshChange(e.target.checked)}
color="warning" color="warning"
sx={{
'& .MuiSwitch-track': {
borderRadius: 20,
},
'& .MuiSwitch-thumb': {
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
transition: 'all 0.2s',
},
'&:hover .MuiSwitch-thumb': {
transform: 'scale(1.1)',
},
}}
/> />
</Box> </Box>
@@ -455,13 +628,21 @@ export default function StorageCleanerPage() {
variant="contained" variant="contained"
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
sx={{ sx={{
py: 1.2, py: 1.3,
borderRadius: 4, borderRadius: 4,
bgcolor: '#ff9800', bgcolor: storageCleanerPageStyles.warningColor,
fontWeight: 800, fontWeight: 800,
fontSize: '0.85rem', fontSize: '0.85rem',
boxShadow: 'none', boxShadow: '0 4px 12px rgba(255, 152, 0, 0.25)',
'&:hover': { bgcolor: '#f57c00', boxShadow: '0 8px 16px rgba(255, 152, 0, 0.2)' }, transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
'&:hover': {
bgcolor: storageCleanerPageStyles.warningDark,
boxShadow: '0 8px 20px rgba(255, 152, 0, 0.35)',
transform: 'translateY(-2px)',
},
'&:active': {
transform: 'translateY(0)',
},
}} }}
disabled={loading} disabled={loading}
fullWidth fullWidth
@@ -471,13 +652,23 @@ export default function StorageCleanerPage() {
{/* Result & Refresh Secondary Action */} {/* Result & Refresh Secondary Action */}
{result && ( {result && (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
<Alert <Alert
severity={result.success ? 'success' : 'error'} severity={result.success ? 'success' : 'error'}
sx={{ sx={{
borderRadius: 2.5, borderRadius: 3,
py: 0, py: 1,
'& .MuiAlert-message': { fontSize: '0.75rem', fontWeight: 600 }, px: 2,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
'& .MuiAlert-message': {
fontSize: '0.8rem',
fontWeight: 600,
lineHeight: 1.4,
},
'& .MuiAlert-icon': {
fontSize: '1.2rem',
mr: 1,
},
}} }}
> >
{result.success ? formatCleaningResult(result) : result.error || '清理失败'} {result.success ? formatCleaningResult(result) : result.error || '清理失败'}
+252 -193
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import dayjs from '@/utils/dayjs'; import dayjs from '@/utils/dayjs';
import { import {
TextField, TextField,
@@ -8,110 +8,105 @@ import {
Typography, Typography,
Box, Box,
IconButton, IconButton,
alpha,
Tooltip, Tooltip,
Theme,
Container, Container,
Fade, Fade,
Divider, Divider,
alpha,
} from '@mui/material'; } from '@mui/material';
import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CheckIcon from '@mui/icons-material/Check';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import Button from '@/components/Button'; import Button from '@/components/Button';
import CopyButton from '@/components/CopyButton';
// ================= 常量配置 ================= import { DATE_FORMAT, ZONES, timestampPageStyles } from '@/config/pageTheme';
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss'; import type { UnitType, ZoneType } from '@/config/pageTheme';
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: 'background.paper',
borderRadius: 3.5,
border: '1px solid',
borderColor: 'grey.100',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
'& fieldset': { border: 'none' },
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
'&.Mui-focused': {
bgcolor: '#fff',
borderColor: 'primary.main',
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.primary.main, 0.1)}`,
},
'&.Mui-error': {
borderColor: 'error.main',
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.error.main, 0.1)}`,
},
},
'& .MuiInputBase-input': {
py: 1.4,
px: 2,
fontSize: '0.9rem',
fontFamily: 'monospace',
fontWeight: 600
},
};
// ================= 子组件:实时时钟 (优化交互) ================= // ================= 子组件:实时时钟 (优化交互) =================
interface LiveClockProps { interface LiveClockProps {
unit: UnitType; unit: UnitType;
onCopy: (val: string) => void;
onUseNow: (val: number) => void; onUseNow: (val: number) => void;
onUnitChange: (u: UnitType) => void; onUnitChange: (u: UnitType) => void;
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void;
} }
const LiveClock = React.memo(({ const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
unit,
onCopy,
onUseNow,
onUnitChange
}: LiveClockProps) => {
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const onUseNowRef = useRef(onUseNow);
const showMessageRef = useRef(showMessage);
useEffect(() => {
onUseNowRef.current = onUseNow;
showMessageRef.current = showMessage;
}, [onUseNow, showMessage]);
useEffect(() => { useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000); const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
const displayVal = useMemo(() => const displayVal = useMemo(
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))), () => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
[now, unit]); [now, unit],
);
const handleUseNow = useCallback(() => {
onUseNowRef.current(now);
}, [now]);
return ( return (
<Box sx={{ <Box
display: 'flex', sx={{
alignItems: 'center', display: 'flex',
justifyContent: 'space-between', alignItems: 'center',
p: 1.8, justifyContent: 'space-between',
mb: 2.5, p: 1.8,
bgcolor: alpha('#2196f3', 0.04), mb: 2.5,
borderRadius: 4, bgcolor: alpha('#2196f3', 0.04),
border: '1px solid', borderRadius: 4,
borderColor: alpha('#2196f3', 0.1) border: '1px solid',
}}> borderColor: alpha('#2196f3', 0.1),
}}
>
<Stack spacing={0.5}> <Stack spacing={0.5}>
<Typography variant="caption" sx={{ color: 'primary.main', fontWeight: 800, fontSize: '0.6rem', textTransform: 'uppercase', letterSpacing: 1 }}> <Typography
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 800,
fontSize: '0.6rem',
textTransform: 'uppercase',
letterSpacing: 1,
}}
>
</Typography> </Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.primary', fontFamily: 'monospace', fontSize: '1.2rem', letterSpacing: '-0.5px', lineHeight: 1.2 }}> <Typography
variant="subtitle2"
sx={{
fontWeight: 800,
color: 'text.primary',
fontFamily: 'monospace',
fontSize: '1.2rem',
letterSpacing: '-0.5px',
lineHeight: 1.2,
}}
>
{displayVal} {displayVal}
</Typography> </Typography>
</Stack> </Stack>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
{/* 胶囊式单位切换器 */} {/* 胶囊式单位切换器 */}
<Box sx={{ <Box
display: 'flex', sx={{
p: 0.4, display: 'flex',
bgcolor: alpha('#2196f3', 0.08), p: 0.4,
borderRadius: 2.5, bgcolor: alpha('#2196f3', 0.08),
border: '1px solid', borderRadius: 2.5,
borderColor: alpha('#2196f3', 0.1) border: '1px solid',
}}> borderColor: alpha('#2196f3', 0.1),
}}
>
{(['ms', 's'] as const).map((u) => ( {(['ms', 's'] as const).map((u) => (
<Box <Box
key={u} key={u}
@@ -134,25 +129,34 @@ const LiveClock = React.memo(({
))} ))}
</Box> </Box>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5, my: 1, borderColor: alpha('#2196f3', 0.1) }} /> <Divider
orientation="vertical"
flexItem
sx={{ mx: 0.5, my: 1, borderColor: alpha('#2196f3', 0.1) }}
/>
<Stack direction="row" spacing={0.5}> <Stack direction="row" spacing={0.5}>
<Tooltip title="填充到下方"> <Tooltip title="填充到下方">
<IconButton <IconButton
size="small" size="small"
onClick={() => onUseNow(now)} onClick={handleUseNow}
sx={{ color: 'primary.main', bgcolor: '#fff', boxShadow: '0 2px 4px rgba(0,0,0,0.05)', '&:hover': { bgcolor: 'primary.main', color: '#fff' } }} sx={{
color: timestampPageStyles.primaryColor,
bgcolor: '#fff',
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
}}
> >
<AccessTimeIcon fontSize="small" /> <AccessTimeIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<IconButton <CopyButton
text={displayVal}
tooltip="复制时间戳"
size="small" size="small"
onClick={() => onCopy(displayVal)} color={timestampPageStyles.primaryColor}
sx={{ color: 'grey.400', '&:hover': { color: 'primary.main' } }} showMessage={showMessage}
> />
<ContentCopyIcon fontSize="small" />
</IconButton>
</Stack> </Stack>
</Stack> </Stack>
</Box> </Box>
@@ -167,27 +171,18 @@ interface ResultViewProps {
mode: 'ts2dt' | 'dt2ts'; mode: 'ts2dt' | 'dt2ts';
unit: UnitType; unit: UnitType;
zone: string; zone: string;
onCopy: (val: string) => void; showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void;
} }
const ResultView = React.memo(({ const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
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(() => { const extraInfo = useMemo(() => {
if (!result) return null; if (!result) return null;
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result))); const d =
mode === 'ts2dt'
? dayjs(result, DATE_FORMAT).tz(zone)
: unit === 'ms'
? dayjs(Number(result))
: dayjs.unix(Number(result));
return { return {
relative: d.fromNow(), relative: d.fromNow(),
@@ -201,19 +196,30 @@ const ResultView = React.memo(({
return ( return (
<Fade in={!!result}> <Fade in={!!result}>
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}> <Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1.2, display: 'block', fontWeight: 800, fontSize: '0.7rem' }}> <Typography
variant="caption"
sx={{
color: 'text.secondary',
mb: 1.2,
display: 'block',
fontWeight: 800,
fontSize: '0.7rem',
}}
>
</Typography> </Typography>
<Box sx={{ <Box
bgcolor: alpha('#2196f3', 0.05), sx={{
p: 2, bgcolor: alpha('#2196f3', 0.05),
borderRadius: 4, p: 2,
position: 'relative', borderRadius: 4,
mb: 2.5, position: 'relative',
border: '1px solid', mb: 2.5,
borderColor: alpha('#2196f3', 0.1) border: '1px solid',
}}> borderColor: alpha('#2196f3', 0.1),
}}
>
<Typography <Typography
variant="body1" variant="body1"
sx={{ sx={{
@@ -222,27 +228,23 @@ const ResultView = React.memo(({
color: 'primary.main', color: 'primary.main',
wordBreak: 'break-all', wordBreak: 'break-all',
pr: 4, pr: 4,
fontSize: '1rem' fontSize: '1rem',
}} }}
> >
{result} {result}
</Typography> </Typography>
<IconButton <CopyButton
text={result}
tooltip="复制结果"
size="small" size="small"
onClick={handleCopy} color={timestampPageStyles.primaryColor}
sx={{ style={{
position: 'absolute', position: 'absolute',
right: 8, right: 8,
top: '50%', top: '50%',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
color: copied ? 'success.main' : 'primary.main',
bgcolor: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
'&:hover': { bgcolor: copied ? 'success.main' : 'primary.main', color: '#fff' }
}} }}
> />
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
</IconButton>
</Box> </Box>
<Stack spacing={1.2}> <Stack spacing={1.2}>
@@ -251,22 +253,38 @@ const ResultView = React.memo(({
{ label: 'ISO 8601', value: extraInfo?.iso }, { label: 'ISO 8601', value: extraInfo?.iso },
{ label: 'UTC 时间', value: extraInfo?.utc }, { label: 'UTC 时间', value: extraInfo?.utc },
].map((item) => ( ].map((item) => (
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}> <Box
<Typography variant="caption" sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}>{item.label}</Typography> key={item.label}
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', px: 1 }}
>
<Typography <Typography
variant="caption" variant="caption"
onClick={() => { if (item.value) onCopy(item.value); }} sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem' }}
sx={{
fontFamily: 'monospace',
color: 'text.secondary',
fontWeight: 600,
fontSize: '0.65rem',
cursor: 'pointer',
'&:hover': { color: 'primary.main' }
}}
> >
{item.value} {item.label}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
variant="caption"
sx={{
fontFamily: 'monospace',
color: 'text.secondary',
fontWeight: 600,
fontSize: '0.65rem',
}}
>
{item.value}
</Typography>
{item.value && (
<CopyButton
text={item.value}
tooltip="复制"
size="small"
color="primary"
showMessage={showMessage}
/>
)}
</Box>
</Box> </Box>
))} ))}
</Stack> </Stack>
@@ -288,30 +306,30 @@ export default function TimestampPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 }); const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showMessage('已复制', { severity: 'success' });
} catch {
showMessage('复制失败', { severity: 'error' });
}
}, [showMessage]);
const convert = useCallback(() => { const convert = useCallback(() => {
if (mode === 'ts2dt') { if (mode === 'ts2dt') {
const rawInput = tsInput.trim(); const rawInput = tsInput.trim();
if (!rawInput) return; if (!rawInput) return;
const num = Number(rawInput); const num = Number(rawInput);
if (isNaN(num)) { setError('无效数字'); return; } if (isNaN(num)) {
setError('无效数字');
return;
}
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num); const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
if (!d.isValid()) { setError('无效时间戳'); return; } if (!d.isValid()) {
setError('无效时间戳');
return;
}
setError(''); setError('');
setResult(d.tz(zone).format(DATE_FORMAT)); setResult(d.tz(zone).format(DATE_FORMAT));
} else { } else {
const rawInput = dtInput.trim(); const rawInput = dtInput.trim();
if (!rawInput) return; if (!rawInput) return;
const d = dayjs.tz(rawInput, DATE_FORMAT, zone); const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
if (!d.isValid()) { setError('格式错误'); return; } if (!d.isValid()) {
setError('格式错误');
return;
}
setError(''); setError('');
const ms = d.valueOf(); const ms = d.valueOf();
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000))); setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
@@ -323,24 +341,39 @@ export default function TimestampPage() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [convert]); }, [convert]);
const handleUseNow = useCallback((now: number) => { const handleUseNow = useCallback(
if (mode === 'ts2dt') { (now: number) => {
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000))); if (mode === 'ts2dt') {
} else { setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT)); } else {
} setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
}, [mode, unit, zone]); }
},
[mode, unit, zone],
);
return ( return (
<Box sx={{ pb: 3 }}> <Box sx={{ bgcolor: '#f5f5f5', minHeight: '100%', pb: 3 }}>
<Container sx={{ py: 2 }}> <Container sx={{ py: 2 }}>
{/* Header with Icon */} {/* Header with Icon */}
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}> <Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5 }}>
<Box sx={{ p: 1, borderRadius: 2.5, bgcolor: alpha('#2196f3', 0.1), color: 'primary.main', display: 'flex' }}> <Box
sx={{
p: 1,
borderRadius: 2.5,
bgcolor: alpha('#2196f3', 0.1),
color: 'primary.main',
display: 'flex',
}}
>
<AccessTimeIcon sx={{ fontSize: 20 }} /> <AccessTimeIcon sx={{ fontSize: 20 }} />
</Box> </Box>
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight={900} sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}> <Typography
variant="subtitle1"
fontWeight={900}
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }}
>
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}> <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
@@ -350,34 +383,48 @@ export default function TimestampPage() {
</Stack> </Stack>
{/* Live Clock Card */} {/* Live Clock Card */}
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} onUnitChange={setUnit} /> <LiveClock
unit={unit}
onUseNow={handleUseNow}
onUnitChange={setUnit}
showMessage={showMessage}
/>
{/* Mode Switcher */} {/* Mode Switcher */}
<Box sx={{ <Box
position: 'relative', sx={{
display: 'flex', position: 'relative',
p: 0.6, display: 'flex',
bgcolor: 'grey.100', p: 0.6,
borderRadius: 4, bgcolor: 'grey.100',
mb: 2.5, borderRadius: 4,
border: '1px solid', mb: 2.5,
borderColor: 'grey.200' border: '1px solid',
}}> borderColor: 'grey.200',
<Box sx={{ }}
position: 'absolute', >
height: 'calc(100% - 10px)', <Box
width: 'calc(50% - 5px)', sx={{
bgcolor: '#fff', position: 'absolute',
borderRadius: 3.5, height: 'calc(100% - 10px)',
boxShadow: '0 4px 12px rgba(0,0,0,0.08)', width: 'calc(50% - 5px)',
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)', bgcolor: '#fff',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)', borderRadius: 3.5,
top: 5, left: 5, boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
}} /> transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
top: 5,
left: 5,
}}
/>
{(['ts2dt', 'dt2ts'] as const).map((m) => ( {(['ts2dt', 'dt2ts'] as const).map((m) => (
<Box <Box
key={m} key={m}
onClick={() => { setMode(m); setError(''); setResult(''); }} onClick={() => {
setMode(m);
setError('');
setResult('');
}}
sx={{ sx={{
flex: 1, flex: 1,
py: 1, py: 1,
@@ -388,7 +435,7 @@ export default function TimestampPage() {
fontWeight: 800, fontWeight: 800,
fontSize: '0.75rem', fontSize: '0.75rem',
color: mode === m ? 'primary.main' : 'text.secondary', color: mode === m ? 'primary.main' : 'text.secondary',
transition: 'color 0.3s' transition: 'color 0.3s',
}} }}
> >
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'} {m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
@@ -399,7 +446,7 @@ export default function TimestampPage() {
{/* Input Area */} {/* Input Area */}
<Stack spacing={2} sx={{ mb: 3 }}> <Stack spacing={2} sx={{ mb: 3 }}>
<TextField <TextField
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT} placeholder={mode === 'ts2dt' ? '输入时间戳...' : DATE_FORMAT}
value={mode === 'ts2dt' ? tsInput : dtInput} value={mode === 'ts2dt' ? tsInput : dtInput}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const val = e.target.value;
@@ -413,20 +460,22 @@ export default function TimestampPage() {
error={!!error} error={!!error}
helperText={error} helperText={error}
fullWidth fullWidth
sx={INPUT_STYLE} sx={timestampPageStyles.INPUT_STYLE}
/> />
<Stack direction="row" spacing={1.5}> <Stack direction="row" spacing={1.5}>
{/* 优化后的单位选择按钮组 */} {/* 优化后的单位选择按钮组 */}
<Box sx={{ <Box
flex: 1, sx={{
display: 'flex', flex: 1,
bgcolor: 'grey.50', display: 'flex',
p: 0.5, bgcolor: 'grey.50',
borderRadius: 3.5, p: 0.5,
border: '1px solid', borderRadius: 3.5,
borderColor: 'grey.100' border: '1px solid',
}}> borderColor: 'grey.100',
}}
>
{(['ms', 's'] as const).map((u) => ( {(['ms', 's'] as const).map((u) => (
<Box <Box
key={u} key={u}
@@ -451,13 +500,20 @@ export default function TimestampPage() {
</Box> </Box>
<Select <Select
fullWidth value={zone} fullWidth
value={zone}
onChange={(e) => setZone(e.target.value as ZoneType)} onChange={(e) => setZone(e.target.value as ZoneType)}
sx={{ ...INPUT_STYLE, flex: 1 }} sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1 }}
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' } } }} MenuProps={{
PaperProps: {
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
},
}}
> >
{ZONES.map((z) => ( {ZONES.map((z) => (
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>{z}</MenuItem> <MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
{z}
</MenuItem>
))} ))}
</Select> </Select>
</Stack> </Stack>
@@ -475,14 +531,17 @@ export default function TimestampPage() {
fontWeight: 800, fontWeight: 800,
fontSize: '0.9rem', fontSize: '0.9rem',
boxShadow: 'none', boxShadow: 'none',
'&:hover': { bgcolor: 'primary.dark', boxShadow: `0 8px 24px ${alpha('#2196f3', 0.2)}` } '&:hover': {
bgcolor: 'primary.dark',
boxShadow: `0 8px 24px ${alpha('#2196f3', 0.2)}`,
},
}} }}
> >
</Button> </Button>
{/* Result View */} {/* Result View */}
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} /> <ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
</Container> </Container>
<GlobalSnackbar {...snackbarProps} /> <GlobalSnackbar {...snackbarProps} />
+8 -6
View File
@@ -1,13 +1,15 @@
export default { export default {
// 对于代码文件: // 对于代码文件:
'*.{ts,tsx,js,jsx}': [ '*.{ts,tsx,js,jsx,mjs}': [
// 1. ESLint: 依然检查具体文件,拦截未使用变量 // 1. Prettier: 全局格式化
'prettier --write',
// 2. ESLint: 检查并自动修复
// --no-warn-ignored: 抑制对忽略文件的警告(eslint.config.ts 中忽略了测试文件)
'eslint --fix --max-warnings=0 --no-warn-ignored', 'eslint --fix --max-warnings=0 --no-warn-ignored',
// 2. TypeScript: 使用函数形式 // 3. TypeScript: 类型检查
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好” () => 'tsc --noEmit',
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
() => 'tsc --noEmit --skipLibCheck',
], ],
// 对于其他文件: // 对于其他文件:
+2177 -195
View File
File diff suppressed because it is too large Load Diff
+16 -5
View File
@@ -1,8 +1,9 @@
{ {
"name": "wxt-react-starter", "name": "testing-tools",
"description": "manifest.json description", "description": "A browser extension providing useful testing tools including timestamp conversion and storage management",
"private": true, "private": false,
"version": "0.0.0", "version": "1.0.0",
"license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "wxt", "dev": "wxt",
@@ -14,7 +15,10 @@
"compile": "tsc --noEmit", "compile": "tsc --noEmit",
"postinstall": "wxt prepare", "postinstall": "wxt prepare",
"prepare": "husky", "prepare": "husky",
"lint": "eslint . --max-warnings=0" "lint": "eslint . --max-warnings=0",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
@@ -23,10 +27,15 @@
"@mui/material": "^7.3.8", "@mui/material": "^7.3.8",
"@webext-core/messaging": "^2.3.0", "@webext-core/messaging": "^2.3.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3" "react-dom": "^19.2.3"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/chrome": "^0.1.36", "@types/chrome": "^0.1.36",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -40,11 +49,13 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.2.0", "globals": "^17.2.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^25.0.0",
"lint-staged": "^16.2.7", "lint-staged": "^16.2.7",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"terser": "^5.46.0", "terser": "^5.46.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.54.0", "typescript-eslint": "^8.54.0",
"vitest": "^2.0.0",
"wxt": "^0.20.6" "wxt": "^0.20.6"
} }
} }
+10 -1
View File
@@ -51,12 +51,21 @@ export function RouterProvider({
if (!syncRoute) return; if (!syncRoute) return;
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => { const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
if (changes[syncKey as string]) { // 同步路由
if (syncRoute && changes[syncKey as string]) {
const newRoute = changes[syncKey as string].newValue as PageType; const newRoute = changes[syncKey as string].newValue as PageType;
if (newRoute && newRoute !== currentPage) { if (newRoute && newRoute !== currentPage) {
setCurrentPage(newRoute); setCurrentPage(newRoute);
} }
} }
// 同步可见页面列表
if (changes['app/visiblePages']) {
setVisiblePages(changes['app/visiblePages'].newValue as PageType[]);
}
// 同步页面排序
if (changes['app/pageOrder']) {
setPageOrder(changes['app/pageOrder'].newValue as PageType[]);
}
}; };
chrome.storage.onChanged.addListener(handleStorageChange); chrome.storage.onChanged.addListener(handleStorageChange);
+2 -10
View File
@@ -24,7 +24,7 @@
// 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容 // 编译目标设置为最新,WXT 底层 Vite 会处理降级兼容
"target": "ESNext", "target": "ESNext",
"types": ["chrome", "webextension-polyfill"], "types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"],
"noImplicitAny": false "noImplicitAny": false
}, },
// 确保包含你的源代码目录 // 确保包含你的源代码目录
@@ -41,13 +41,5 @@
"components", "components",
"eslint.config.ts" "eslint.config.ts"
], ],
"exclude": [ "exclude": ["node_modules", ".wxt", "dist"]
"node_modules",
".wxt",
"components/__tests__",
"**/*.test.tsx",
"**/*.test.ts",
"vitest.config.ts",
"vitest.setup.ts"
]
} }
+9 -1
View File
@@ -1,4 +1,10 @@
export type PageType = 'dashboard' | 'timestamp' | 'storageCleaner' | 'openUrl' | 'openUrlViewer'; export type PageType =
| 'dashboard'
| 'timestamp'
| 'storageCleaner'
| 'openUrl'
| 'qrCode'
| 'openUrlViewer';
export interface StorageSchema { export interface StorageSchema {
'app/currentRoute': PageType; 'app/currentRoute': PageType;
@@ -11,6 +17,8 @@ export interface StorageSchema {
'storageCleaner/preferences': StorageCleanerPreferences; 'storageCleaner/preferences': StorageCleanerPreferences;
'openUrl/preferences': OpenUrlPreferences; 'openUrl/preferences': OpenUrlPreferences;
'openUrl/currentUrl': string; 'openUrl/currentUrl': string;
'qrCode/qrExpanded': boolean;
'qrCode/urlExpanded': boolean;
} }
export interface StorageCleanerPreferences { export interface StorageCleanerPreferences {
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
isRestrictedUrl,
formatSize,
} from '../storageCleaner';
describe('storageCleaner utils', () => {
describe('isRestrictedUrl', () => {
it('should return true for chrome:// URLs', () => {
expect(isRestrictedUrl('chrome://settings')).toBe(true);
});
it('should return true for chrome-extension:// URLs', () => {
expect(isRestrictedUrl('chrome-extension://abc123/background.html')).toBe(true);
});
it('should return true for about:// URLs', () => {
expect(isRestrictedUrl('about:blank')).toBe(true);
});
it('should return true for edge:// URLs', () => {
expect(isRestrictedUrl('edge://settings')).toBe(true);
});
it('should return true for view-source:// URLs', () => {
expect(isRestrictedUrl('view-source:https://example.com')).toBe(true);
});
it('should return true for file:// URLs', () => {
expect(isRestrictedUrl('file:///path/to/file')).toBe(true);
});
it('should return true for data:// URLs', () => {
expect(isRestrictedUrl('data:text/html,<h1>Hello</h1>')).toBe(true);
});
it('should return false for http:// URLs', () => {
expect(isRestrictedUrl('http://example.com')).toBe(false);
});
it('should return false for https:// URLs', () => {
expect(isRestrictedUrl('https://example.com')).toBe(false);
});
it('should return true for undefined URL', () => {
expect(isRestrictedUrl(undefined)).toBe(true);
});
it('should return true for empty string', () => {
expect(isRestrictedUrl('')).toBe(true);
});
});
describe('formatSize', () => {
it('should return "0 B" for 0 bytes', () => {
expect(formatSize(0)).toBe('0 B');
});
it('should format bytes correctly', () => {
expect(formatSize(500)).toBe('500 B');
});
it('should format kilobytes correctly', () => {
expect(formatSize(1024)).toBe('1 KB');
expect(formatSize(1536)).toBe('1.5 KB');
expect(formatSize(2048)).toBe('2 KB');
});
it('should format megabytes correctly', () => {
expect(formatSize(1048576)).toBe('1 MB');
expect(formatSize(1572864)).toBe('1.5 MB');
expect(formatSize(5242880)).toBe('5 MB');
});
it('should format gigabytes correctly', () => {
expect(formatSize(1073741824)).toBe('1 GB');
expect(formatSize(2147483648)).toBe('2 GB');
});
it('should handle edge cases', () => {
expect(formatSize(1)).toBe('1 B');
expect(formatSize(1023)).toBe('1023 B');
expect(formatSize(1025)).toBe('1 KB');
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { useSnackbar } from '@/components/GlobalSnackbar';
/**
* 复制文本到剪贴板
* @param text 要复制的文本
* @param showMessage 显示消息的函数
* @returns Promise<boolean> 是否复制成功
*/
export const copyToClipboard = async (
text: string,
showMessage?: (message: string, options?: { severity: 'success' | 'error' }) => void,
): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text);
showMessage?.('已复制', { severity: 'success' });
return true;
} catch (error) {
console.error('复制失败:', error);
showMessage?.('复制失败', { severity: 'error' });
return false;
}
};
/**
* 自定义 Hook: 用于处理剪贴板操作
* @returns 包含复制函数和 snackbarProps 的对象
*/
export const useClipboard = () => {
const { snackbarProps, showMessage } = useSnackbar({ autoHideDuration: 1500 });
const copy = async (text: string): Promise<boolean> => {
return copyToClipboard(text, showMessage);
};
return { copy, snackbarProps };
};
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()] as any,
test: {
environment: 'jsdom',
globals: true,
setupFiles: './vitest.setup.ts',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', '.wxt', 'dist', 'build'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'.wxt/',
'dist/',
'build/',
'**/*.d.ts',
'vitest.setup.ts',
'vitest.config.ts',
],
},
alias: {
'@': resolve(__dirname, './'),
},
},
});
+56 -13
View File
@@ -1,24 +1,67 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { vi } from 'vitest'; import { vi, beforeEach, afterEach } from 'vitest';
const storageMock = {
local: {
get: vi.fn(),
set: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
session: {
get: vi.fn(),
set: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
hasListener: vi.fn(),
},
};
// 模拟 chrome storage API
Object.defineProperty(global, 'chrome', { Object.defineProperty(global, 'chrome', {
value: { value: {
storage: { storage: storageMock,
local: { tabs: {
get: vi.fn(), query: vi.fn().mockResolvedValue([]),
set: vi.fn(), get: vi.fn(),
remove: vi.fn(), sendMessage: vi.fn().mockResolvedValue(undefined),
},
runtime: {
id: 'test-extension-id',
getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),
openOptionsPage: vi.fn(),
onInstalled: {
addListener: vi.fn(),
removeListener: vi.fn(),
}, },
}, },
action: {
onClicked: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
sidePanel: {
open: vi.fn().mockResolvedValue(undefined),
},
scripting: {
executeScript: vi.fn().mockResolvedValue([{ result: undefined }]),
},
cookies: {
getAll: vi.fn().mockResolvedValue([]),
remove: vi.fn().mockResolvedValue(undefined),
},
}, },
writable: true, writable: true,
}); });
// 模拟 navigator.clipboard beforeEach(() => {
Object.defineProperty(navigator, 'clipboard', { vi.clearAllMocks();
value: { });
writeText: vi.fn().mockResolvedValue(undefined),
}, afterEach(() => {
writable: true, vi.restoreAllMocks();
}); });