From be5e2f02eedad116b7d5a0815913a1884515e513 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:31:14 +0800 Subject: [PATCH] Develop (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 文档说明新功能 --- .claude/settings.local.json | 19 - CLAUDE.md | 183 -- GEMINI.md | 72 - LICENSE | 21 + README.md | 149 +- components/CopyButton.tsx | 77 + components/GlobalSnackbar.tsx | 82 +- components/ToolCard.tsx | 5 +- components/TopBar.tsx | 24 +- components/__tests__/Button.test.tsx | 73 + components/__tests__/GlobalSnackbar.test.tsx | 150 ++ components/__tests__/RouterContainer.test.tsx | 76 + .../__tests__/StorageCleanerConfirm.test.tsx | 136 + components/__tests__/ToolCard.test.tsx | 150 ++ components/__tests__/TopBar.test.tsx | 75 + config/__tests__/routes.test.ts | 138 + config/pageTheme.ts | 124 + config/routes.ts | 7 + .../plans/2026-03-20-storage-cleaner-plan.md | 880 ------ .../plans/2026-04-05-popup-layout-fix-plan.md | 32 - .../2026-03-20-storage-cleaner-design.md | 387 --- ...6-03-25-claude-md-reorganization-design.md | 97 - .../2026-04-05-popup-layout-fix-design.md | 48 - entrypoints/background.ts | 6 +- entrypoints/options/App.tsx | 39 +- entrypoints/popup/App.css | 14 +- entrypoints/popup/App.tsx | 5 +- entrypoints/popup/pages/DashboardPage.tsx | 21 +- entrypoints/popup/pages/OpenUrlPage.tsx | 62 +- entrypoints/popup/pages/OpenUrlViewerPage.tsx | 121 +- entrypoints/popup/pages/QrCodePage.tsx | 560 ++++ .../popup/pages/StorageCleanerPage.tsx | 413 ++- entrypoints/popup/pages/TimestampPage.tsx | 519 ++-- lint-staged.config.mjs | 14 +- package-lock.json | 2372 +++++++++++++++-- package.json | 21 +- providers/RouterProvider.tsx | 11 +- tsconfig.json | 12 +- types/storage.d.ts | 10 +- utils/__tests__/storageCleaner.test.ts | 86 + utils/clipboard.ts | 36 + vitest.config.ts | 30 + vitest.setup.ts | 69 +- 43 files changed, 4943 insertions(+), 2483 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 CLAUDE.md delete mode 100644 GEMINI.md create mode 100644 LICENSE create mode 100644 components/CopyButton.tsx create mode 100644 components/__tests__/Button.test.tsx create mode 100644 components/__tests__/GlobalSnackbar.test.tsx create mode 100644 components/__tests__/RouterContainer.test.tsx create mode 100644 components/__tests__/StorageCleanerConfirm.test.tsx create mode 100644 components/__tests__/ToolCard.test.tsx create mode 100644 components/__tests__/TopBar.test.tsx create mode 100644 config/__tests__/routes.test.ts create mode 100644 config/pageTheme.ts delete mode 100644 docs/superpowers/plans/2026-03-20-storage-cleaner-plan.md delete mode 100644 docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md delete mode 100644 docs/superpowers/specs/2026-03-20-storage-cleaner-design.md delete mode 100644 docs/superpowers/specs/2026-03-25-claude-md-reorganization-design.md delete mode 100644 docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md create mode 100644 entrypoints/popup/pages/QrCodePage.tsx create mode 100644 utils/__tests__/storageCleaner.test.ts create mode 100644 utils/clipboard.ts create mode 100644 vitest.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8316093..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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:*)" - ] - } -} diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f98b618..0000000 --- a/CLAUDE.md +++ /dev/null @@ -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 (``), 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: [''] // 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 diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 0e3a523..0000000 --- a/GEMINI.md +++ /dev/null @@ -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`。 -- **宿主权限**: `` (用于在所有页面注入 content 脚本)。 -- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。 - -## 6. 维护者提示 - -在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2da385f --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 20cef41..307395b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ # 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) @@ -24,23 +61,83 @@ Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转 - **日期处理**: dayjs (含 UTC 和时区插件) - **通信**: @webext-core/messaging - **存储**: 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/ # 浏览器扩展入口点 -│ ├── popup/ # 扩展弹窗界面(时间戳转换页面) -│ ├── options/ # 选项页面 -│ ├── background.ts # 后台脚本 -│ └── content.ts # 内容脚本 -├── utils/ # 工具函数(存储、日期处理、消息通信) -├── types/ # TypeScript 类型定义 -├── public/ # 静态资源 -├── wxt.config.ts # WXT 配置文件 -├── package.json # 项目依赖和脚本 -└── README.md # 项目说明文档 +│ ├── popup/ # 扩展弹窗界面 +│ │ ├── App.tsx +│ │ ├── main.tsx +│ │ └── pages/ # 页面组件 +│ │ ├── DashboardPage.tsx +│ │ ├── OpenUrlPage.tsx +│ │ ├── OpenUrlViewerPage.tsx +│ │ ├── QrCodePage.tsx +│ │ ├── StorageCleanerPage.tsx +│ │ └── 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 @@ -80,17 +177,25 @@ npm run build:firefox # Chrome 浏览器 npm run zip -# Firefox +# Firefox 浏览器 npm run zip:firefox ``` -### 5. 其他命令 +### 5. 代码质量 ```bash npm run compile # TypeScript 类型检查 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` - 本地数据存储 - `clipboardWrite` - 剪贴板写入(复制功能) - `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入 -- `debugger` - 调试器权限 +- `cookies` - Cookie 访问 +- `sidePanel` - 侧边栏支持 - `` - 访问所有网站内容(内容脚本注入) ## 主要依赖 @@ -107,15 +213,14 @@ npm run lint # ESLint 代码检查 - `@mui/material` - UI 组件库 - `dayjs` - 日期处理 - `@webext-core/messaging` - 扩展消息通信 +- `vitest` - 测试框架 +- `@testing-library/react` - React 组件测试 -## 贡献指南 +## 浏览器兼容性 -1. Fork 项目 -2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) -3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) -4. 推送到分支 (`git push origin feature/AmazingFeature`) -5. 创建 Pull Request +- Chrome (推荐) +- Firefox ## 许可证 -此项目为私有项目 (private: true),仅供内部使用。 +此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。 diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx new file mode 100644 index 0000000..85678a5 --- /dev/null +++ b/components/CopyButton.tsx @@ -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 = ({ + 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 ( + + + {copied ? ( + + ) : ( + + )} + + + ); +}; + +export default CopyButton; diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index 11e3840..60d312e 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -1,5 +1,5 @@ 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'; @@ -61,39 +61,30 @@ export function GlobalSnackbar({ onClose, severity = defaultProps.severity, autoHideDuration = defaultProps.autoHideDuration, - anchorOrigin = defaultProps.anchorOrigin, showAlert = defaultProps.showAlert, hideIcon = defaultProps.hideIcon, - sx, - alertSx, }: GlobalSnackbarProps) { - // 共享的固定定位样式 - const fixedSx: SxProps = { - position: 'fixed', - bottom: '24px !important', // 固定在视口底部 - left: '50% !important', - transform: 'translateX(-50%) !important', - zIndex: (theme) => theme.zIndex.tooltip + 100, - maxWidth: '90%', - width: 'max-content', - }; - - if (showAlert) { - return ( + // 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下) + return ( + - `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, - - '& .MuiAlert-icon': { - mr: 0.5, - fontSize: '1.1rem', - color: '#fff' - }, - '& .MuiAlert-message': { - color: '#fff', - padding: '6px 0', - textAlign: 'center' - } - }, - ...(Array.isArray(alertSx) ? alertSx : [alertSx]), - ]} - > - {message} - + boxShadow: (theme: Theme) => + `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, + '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' }, + '& .MuiAlert-message': { color: '#fff', padding: '6px 0' }, + }} + > + {message} + + ) : undefined} - ); - } - - return ( - + ); } diff --git a/components/ToolCard.tsx b/components/ToolCard.tsx index 572370e..ed2ceab 100644 --- a/components/ToolCard.tsx +++ b/components/ToolCard.tsx @@ -11,15 +11,16 @@ interface ToolCardProps { icon: React.ReactNode; onClick: () => void; 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 ( void }) { const { currentPage, goBack } = useRouter(); + const isDetachedMode = useMemo(() => { + return new URLSearchParams(window.location.search).get('mode') === 'detached'; + }, []); + const handleDetach = () => { - // 弹出脱离窗口 (以独立面板形式打开当前 URL) + // 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached) + const url = new URL(window.location.href); + url.searchParams.set('mode', 'detached'); + chrome.windows.create({ - url: window.location.href, + url: url.toString(), type: 'panel', width: 420, height: 600 @@ -62,11 +70,13 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) - - - - - + {!isDetachedMode && ( + + + + + + )} diff --git a/components/__tests__/Button.test.tsx b/components/__tests__/Button.test.tsx new file mode 100644 index 0000000..c399c35 --- /dev/null +++ b/components/__tests__/Button.test.tsx @@ -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(); + const button = screen.getByRole('button', { name: /click me/i }); + expect(button).toBeInTheDocument(); + }); + + it('should render with custom text', () => { + render(); + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); + }); + + it('should render with different variants', () => { + const { rerender } = render(); + expect(screen.getByRole('button', { name: /contained/i })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('button', { name: /outlined/i })).toBeInTheDocument(); + + rerender(); + expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument(); + }); + }); + + describe('Interaction', () => { + it('should call onClick when clicked', () => { + const handleClick = vi.fn(); + render(); + + 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(); + + fireEvent.click(screen.getByRole('button', { name: /disabled button/i })); + expect(handleClick).not.toHaveBeenCalled(); + }); + }); + + describe('Styling', () => { + it('should apply fullWidth prop', () => { + render(); + const button = screen.getByRole('button', { name: /full width/i }); + expect(button).toHaveClass('MuiButton-fullWidth'); + }); + }); + + describe('States', () => { + it('should render in loading state', () => { + render(); + const button = screen.getByRole('button', { name: /loading/i }); + expect(button).toHaveClass('MuiButton-loading'); + }); + + it('should render as disabled', () => { + render(); + const button = screen.getByRole('button', { name: /disabled/i }); + expect(button).toBeDisabled(); + }); + }); +}); \ No newline at end of file diff --git a/components/__tests__/GlobalSnackbar.test.tsx b/components/__tests__/GlobalSnackbar.test.tsx new file mode 100644 index 0000000..183b586 --- /dev/null +++ b/components/__tests__/GlobalSnackbar.test.tsx @@ -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(); + expect(screen.getByText('Test message')).toBeInTheDocument(); + }); + + it.each(['success', 'info', 'warning', 'error'] as const)( + 'should render with %s severity', + (severity) => { + render( + + ); + expect(screen.getByText('Test message')).toBeInTheDocument(); + } + ); + + it('should render with custom anchor origin', () => { + render( + + ); + expect(screen.getByText('Test message')).toBeInTheDocument(); + }); + }); + + describe('useSnackbar Hook', () => { + it('should return initial state', () => { + const TestComponent = () => { + const { snackbarProps } = useSnackbar(); + return ( +
+ {String(snackbarProps.open)} + {snackbarProps.message} +
+ ); + }; + + render(); + 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 ( +
+ + {snackbarProps.message} + {String(snackbarProps.open)} +
+ ); + }; + + render(); + + 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 ( +
+ + + {String(snackbarProps.open)} +
+ ); + }; + + render(); + + 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 ( +
+ + {snackbarProps.severity} +
+ ); + }; + + render(); + + 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(); + + await act(async () => { + // Trigger close by timeout (autoHideDuration) + }); + + // Note: MUI Snackbar's close behavior depends on autoHideDuration + }); + }); +}); \ No newline at end of file diff --git a/components/__tests__/RouterContainer.test.tsx b/components/__tests__/RouterContainer.test.tsx new file mode 100644 index 0000000..a77ea07 --- /dev/null +++ b/components/__tests__/RouterContainer.test.tsx @@ -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({ui}); + }; + + describe('Rendering', () => { + it('should render loading state when isLoaded is false', () => { + mockRouterValue.isLoaded = false; + renderWithProvider(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('should render page content when isLoaded is true', () => { + mockRouterValue.isLoaded = true; + mockRouterValue.currentPage = 'dashboard'; + const { container } = renderWithProvider(); + expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument(); + }); + }); + + describe('Animation classes', () => { + it('should apply dashboard animation class on dashboard page', () => { + mockRouterValue.currentPage = 'dashboard'; + renderWithProvider(); + const box = document.querySelector('.page-transition-dashboard'); + expect(box).toBeInTheDocument(); + }); + + it('should apply enter animation class on non-dashboard page', () => { + mockRouterValue.currentPage = 'timestamp'; + renderWithProvider(); + const box = document.querySelector('.page-transition-enter'); + expect(box).toBeInTheDocument(); + }); + }); + + describe('Route handling', () => { + it('should update when currentPage changes', () => { + const { rerender } = renderWithProvider(); + + mockRouterValue.currentPage = 'timestamp'; + rerender({}); + + const box = document.querySelector('.page-transition-enter'); + expect(box).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/components/__tests__/StorageCleanerConfirm.test.tsx b/components/__tests__/StorageCleanerConfirm.test.tsx new file mode 100644 index 0000000..346a5d7 --- /dev/null +++ b/components/__tests__/StorageCleanerConfirm.test.tsx @@ -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>) => { + return render( + + ); + }; + + 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(); + }); + }); +}); \ No newline at end of file diff --git a/components/__tests__/ToolCard.test.tsx b/components/__tests__/ToolCard.test.tsx new file mode 100644 index 0000000..7b275ba --- /dev/null +++ b/components/__tests__/ToolCard.test.tsx @@ -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( + } + 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( + } + onClick={() => {}} + /> + ); + + expect(screen.getByText('Title Only')).toBeInTheDocument(); + }); + + it('should render icon', () => { + render( + } + onClick={() => {}} + /> + ); + + expect(screen.getByTestId('test-icon')).toBeInTheDocument(); + }); + + it('should render snapshot content when provided', () => { + render( + } + onClick={() => {}} + snapshot={
Snapshot Content
} + /> + ); + + expect(screen.getByTestId('snapshot')).toBeInTheDocument(); + }); + + it('should not render snapshot section when not provided', () => { + const { container } = render( + } + onClick={() => {}} + /> + ); + + expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); + }); + }); + + describe('AI Badge', () => { + it('should render AI badge when hasAI is true', () => { + render( + } + onClick={() => {}} + /> + ); + + const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon'); + expect(autoAwesomeIcon).toBeInTheDocument(); + }); + + it('should not render AI badge when hasAI is false', () => { + render( + } + onClick={() => {}} + /> + ); + + const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon'); + expect(autoAwesomeIcon).not.toBeInTheDocument(); + }); + }); + + describe('Interaction', () => { + it('should call onClick when clicked', () => { + const handleClick = vi.fn(); + render( + } + 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( + } + onClick={() => {}} + /> + ); + + const iconContainer = container.querySelector('.MuiBox-root > div'); + expect(iconContainer).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx new file mode 100644 index 0000000..90d5f5b --- /dev/null +++ b/components/__tests__/TopBar.test.tsx @@ -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({ui}); + }; + + describe('Rendering', () => { + it('should render with default title', () => { + renderWithProvider(); + expect(screen.getByText('Testing Tools')).toBeInTheDocument(); + }); + + it('should render back button when not on dashboard', () => { + mockRouterValue.currentPage = 'timestamp'; + renderWithProvider(); + expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument(); + }); + + it('should not render back button on dashboard', () => { + mockRouterValue.currentPage = 'dashboard'; + renderWithProvider(); + expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument(); + }); + + it('should render settings button', () => { + renderWithProvider(); + expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument(); + }); + }); + + describe('Interaction', () => { + it('should call onOpenOptions when settings button is clicked', () => { + const handleOpenOptions = vi.fn(); + renderWithProvider(); + + fireEvent.click(screen.getByTestId('SettingsIcon')); + expect(handleOpenOptions).toHaveBeenCalledTimes(1); + }); + + it('should call goBack when back button is clicked', () => { + mockRouterValue.currentPage = 'timestamp'; + renderWithProvider(); + + fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon')); + expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/config/__tests__/routes.test.ts b/config/__tests__/routes.test.ts new file mode 100644 index 0000000..34fbfe6 --- /dev/null +++ b/config/__tests__/routes.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/config/pageTheme.ts b/config/pageTheme.ts new file mode 100644 index 0000000..b71ab93 --- /dev/null +++ b/config/pageTheme.ts @@ -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; diff --git a/config/routes.ts b/config/routes.ts index ebed160..f578e38 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -4,6 +4,7 @@ import TimestampPage from '@/entrypoints/popup/pages/TimestampPage'; import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage'; import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage'; import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage'; +import QrCodePage from '@/entrypoints/popup/pages/QrCodePage'; export interface RouteConfig { key: PageType; @@ -37,6 +38,12 @@ export const ROUTES: RouteConfig[] = [ defaultVisible: true, component: OpenUrlPage, }, + { + key: 'qrCode', + label: '二维码工具', + defaultVisible: true, + component: QrCodePage, + }, { key: 'openUrlViewer', label: '查看', diff --git a/docs/superpowers/plans/2026-03-20-storage-cleaner-plan.md b/docs/superpowers/plans/2026-03-20-storage-cleaner-plan.md deleted file mode 100644 index 96325cb..0000000 --- a/docs/superpowers/plans/2026-03-20-storage-cleaner-plan.md +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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((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 { - 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 { - 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 { - 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(''); - const [error, setError] = useState(''); - const [options, setOptions] = useState(DEFAULT_OPTIONS); - const [autoRefresh, setAutoRefresh] = useState(true); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [showConfirm, setShowConfirm] = useState(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 ( - - }> - {error} - - - ); - } - - return ( - - {/* Header */} - - - 存储清理 - - - 当前页面: {domain || '加载中...'} - - - - {/* Storage Type Options */} - - - 选择要清理的存储类型: - - - handleOptionChange('localStorage')} - /> - } - label="localStorage" - /> - handleOptionChange('sessionStorage')} - /> - } - label="sessionStorage" - /> - handleOptionChange('indexedDB')} - /> - } - label="IndexedDB" - /> - handleOptionChange('cookies')} - /> - } - label="Cookies" - /> - handleOptionChange('cacheStorage')} - /> - } - label="Cache Storage" - /> - handleOptionChange('serviceWorkers')} - /> - } - label="Service Workers" - /> - - - - {/* Auto Refresh Option */} - - setAutoRefresh(e.target.checked)} - /> - } - label="清理完成后自动刷新页面" - /> - - - {/* Action Buttons */} - - - - - {/* Result Display */} - {result && ( - - - {result.success ? formatCleaningResult(result) : result.error || '清理失败'} - - {!autoRefresh && result.success && ( - - )} - - )} - - {/* Confirmation Dialog */} - {showConfirm && ( - - 确认清理 - - 将清理以下存储类型: - - - {options.localStorage && ( - - localStorage - )} - {options.sessionStorage && ( - - sessionStorage - )} - {options.indexedDB && - IndexedDB} - {options.cookies && - Cookies} - {options.cacheStorage && ( - - Cache Storage - )} - {options.serviceWorkers && ( - - Service Workers - )} - - - 此操作不可撤销。 - - - - - - - )} - - {/* Snackbar */} - setSnackbar({ ...snackbar, open: false })} - > - - {snackbar.message} - - - - ); -} -``` - -- [ ] **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('timestamp'); - - return ( -
- - - - - {currentPage === 'timestamp' && } - {currentPage === 'storageCleaner' && } -
- ); -} - -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 - ``` - -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 diff --git a/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md b/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md deleted file mode 100644 index a070d99..0000000 --- a/docs/superpowers/plans/2026-04-05-popup-layout-fix-plan.md +++ /dev/null @@ -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。 -- 在“存储清理”页面展开所有选项,观察右侧是否出现极细滚动条,且不会挤压内容。 diff --git a/docs/superpowers/specs/2026-03-20-storage-cleaner-design.md b/docs/superpowers/specs/2026-03-20-storage-cleaner-design.md deleted file mode 100644 index d81d08e..0000000 --- a/docs/superpowers/specs/2026-03-20-storage-cleaner-design.md +++ /dev/null @@ -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('timestamp'); - - return ( -
- - - - - {currentPage === 'timestamp' && } - {currentPage === 'storageCleaner' && } -
- ); -} -``` - -## 用户界面设计 - -### 页面组成 - -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. 测试用户偏好持久化 - -## 后续优化 - -- 显示清理前的存储使用情况 -- 支持批量清理多个标签页 -- 支持自定义域名清理 diff --git a/docs/superpowers/specs/2026-03-25-claude-md-reorganization-design.md b/docs/superpowers/specs/2026-03-25-claude-md-reorganization-design.md deleted file mode 100644 index ffda06f..0000000 --- a/docs/superpowers/specs/2026-03-25-claude-md-reorganization-design.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md b/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md deleted file mode 100644 index bdadb8b..0000000 --- a/docs/superpowers/specs/2026-04-05-popup-layout-fix-design.md +++ /dev/null @@ -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 展开时,页面水平方向不发生位移。 diff --git a/entrypoints/background.ts b/entrypoints/background.ts index ca0b70a..916b555 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -56,7 +56,9 @@ export default defineBackground(() => { ); }); - chrome.tabs.onUpdated.addListener((tabId) => { - console.log('加载完成的 Tab ID:', tabId); + chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === 'complete') { + console.log('加载完成的 Tab ID:', tabId); + } }); }); diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index c478cd6..0b8ff01 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -5,8 +5,6 @@ import { Paper, Switch, Button, - Snackbar, - Alert, CircularProgress, Stack, IconButton, @@ -16,14 +14,14 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import type { PageType } from '@/types/storage'; 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() { const [visiblePages, setVisiblePages] = useState([]); const [pageOrder, setPageOrder] = useState([]); const [isLoaded, setIsLoaded] = useState(false); - const [toast, setToast] = useState(null); - const [toastSeverity, setToastSeverity] = useState<'success' | 'info' | 'warning'>('info'); + const { snackbarProps, showMessage } = useSnackbar(); useEffect(() => { loadConfig(); @@ -32,18 +30,14 @@ function App() { const loadConfig = async () => { try { const [savedVisible, savedOrder] = await Promise.all([ - storageUtil.get('app/visiblePages', [ - 'timestamp', - 'storageCleaner', - 'openUrl', - ] as PageType[]), + storageUtil.get('app/visiblePages', getDefaultVisibleRoutes()), storageUtil.get('app/pageOrder', getDefaultPageOrder()), ]); - setVisiblePages(savedVisible ?? ['timestamp', 'storageCleaner', 'openUrl']); + setVisiblePages(savedVisible ?? getDefaultVisibleRoutes()); setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder()); } catch (error) { console.error('Failed to load config:', error); - setVisiblePages(['timestamp', 'storageCleaner', 'openUrl']); + setVisiblePages(getDefaultVisibleRoutes()); setPageOrder(getDefaultPageOrder()); } finally { setIsLoaded(true); @@ -113,12 +107,9 @@ function App() { }; const showToast = (message: string, severity: 'success' | 'info' | 'warning') => { - setToast(message); - setToastSeverity(severity); + showMessage(message, { severity }); }; - const handleCloseToast = () => setToast(null); - if (!isLoaded) { return ( - - - {toast} - - + ); } diff --git a/entrypoints/popup/App.css b/entrypoints/popup/App.css index 33dddb5..174469a 100644 --- a/entrypoints/popup/App.css +++ b/entrypoints/popup/App.css @@ -19,7 +19,7 @@ --sb-track-color: transparent; } -/* 强制固定视口尺寸 */ +/* 默认 Popup 尺寸 */ html, body, #root { @@ -30,6 +30,18 @@ body, 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 { width: var(--sb-width); diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index a9e55e7..4ab7f2f 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -10,7 +10,10 @@ function App() { return ( -
+
diff --git a/entrypoints/popup/pages/DashboardPage.tsx b/entrypoints/popup/pages/DashboardPage.tsx index e76ad73..85fec59 100644 --- a/entrypoints/popup/pages/DashboardPage.tsx +++ b/entrypoints/popup/pages/DashboardPage.tsx @@ -4,9 +4,11 @@ import ToolCard from '@/components/ToolCard'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import StorageIcon from '@mui/icons-material/Storage'; import LanguageIcon from '@mui/icons-material/Language'; +import QrCodeIcon from '@mui/icons-material/QrCode'; import type { PageType } from '@/types/storage'; import { useEffect, useState } from 'react'; import dayjs from '@/utils/dayjs'; +import { dashboardPageStyles } from '@/config/pageTheme'; export default function DashboardPage() { const { navigateTo, visiblePages, pageOrder } = useRouter(); @@ -30,13 +32,14 @@ export default function DashboardPage() { colorCode="#2196f3" icon={} onClick={() => navigateTo('timestamp')} + cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} snapshot={ @@ -58,6 +61,7 @@ export default function DashboardPage() { colorCode="#ff9800" icon={} onClick={() => navigateTo('storageCleaner')} + cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} /> ); case 'openUrl': @@ -69,6 +73,19 @@ export default function DashboardPage() { colorCode="#9c27b0" icon={} onClick={() => navigateTo('openUrl')} + cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} + /> + ); + case 'qrCode': + return ( + } + onClick={() => navigateTo('qrCode')} + cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} /> ); default: @@ -77,7 +94,7 @@ export default function DashboardPage() { }; return ( - + {pageOrder.map((key) => (isVisible(key) ? renderCard(key) : null))} diff --git a/entrypoints/popup/pages/OpenUrlPage.tsx b/entrypoints/popup/pages/OpenUrlPage.tsx index 6bccd83..bca0838 100644 --- a/entrypoints/popup/pages/OpenUrlPage.tsx +++ b/entrypoints/popup/pages/OpenUrlPage.tsx @@ -11,7 +11,6 @@ import { Container, Stack, alpha, - Theme, Tooltip, } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -23,40 +22,10 @@ import LinkIcon from '@mui/icons-material/Link'; import Button from '@/components/Button'; import GlobalSnackbar, { useSnackbar } from '@/components/GlobalSnackbar'; import { storageUtil } from '@/utils/chromeStorage'; -import { useRouter } from '@/providers/RouterProvider'; import type { OpenUrlPreferences, OpenUrlEntry } from '@/types/storage'; +import { openUrlPageStyles, dashboardPageStyles } from '@/config/pageTheme'; -const THEME_COLOR = '#9c27b0'; - -const INPUT_STYLE = { - '& .MuiOutlinedInput-root': { - bgcolor: 'background.paper', - borderRadius: 3.5, - border: '1px solid', - borderColor: 'grey.100', - transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', - '& fieldset': { border: 'none' }, - '&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' }, - '&.Mui-focused': { - bgcolor: '#fff', - borderColor: THEME_COLOR, - boxShadow: (_theme: Theme) => `0 0 0 4px ${alpha(THEME_COLOR, 0.1)}`, - }, - }, - '& .MuiInputBase-input': { - py: 1.2, - px: 2, - fontSize: '0.85rem', - fontWeight: 600, - }, - '& .MuiInputLabel-root': { - fontSize: '0.85rem', - fontWeight: 700, - color: 'text.secondary', - mb: 0.5, - '&.Mui-focused': { color: THEME_COLOR }, - }, -}; +const THEME_COLOR = openUrlPageStyles.themeColor; const DEFAULT_PREFERENCES: OpenUrlPreferences = { entries: [], @@ -68,7 +37,6 @@ export default function OpenUrlPage() { const [newUrl, setNewUrl] = useState(''); const [isLoaded, setIsLoaded] = useState(false); const { snackbarProps, showMessage } = useSnackbar(); - const { syncNavigation } = useRouter(); const showMixedContentWarning = newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1'); @@ -139,8 +107,10 @@ export default function OpenUrlPage() { const handleOpenInSidebar = async (entry: OpenUrlEntry) => { try { + // 存储目标 URL await storageUtil.set('openUrl/currentUrl', entry.url); - syncNavigation('openUrlViewer'); + // 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由 + await storageUtil.set('app/sidepanelRoute', 'openUrlViewer'); const [currentTab] = await chrome.tabs.query({ active: true, @@ -175,7 +145,7 @@ export default function OpenUrlPage() { }; return ( - + {/* Header */} @@ -196,10 +166,10 @@ export default function OpenUrlPage() { fontWeight={900} sx={{ letterSpacing: '-0.5px', lineHeight: 1.2 }} > - URL 实验室 + URL 工具 - 多环境跳转与安全性预检 + 快速打开 URL 或复制链接 @@ -224,8 +194,12 @@ export default function OpenUrlPage() { onChange={(e) => setNewName(e.target.value)} fullWidth variant="outlined" - sx={INPUT_STYLE} - InputLabelProps={{ shrink: true }} + sx={openUrlPageStyles.INPUT_STYLE} + slotProps={{ + inputLabel: { + shrink: true, + }, + }} /> setNewUrl(e.target.value)} fullWidth variant="outlined" - sx={INPUT_STYLE} - InputLabelProps={{ shrink: true }} + sx={openUrlPageStyles.INPUT_STYLE} + slotProps={{ + inputLabel: { + shrink: true, + }, + }} /> {showMixedContentWarning && ( diff --git a/entrypoints/popup/pages/OpenUrlViewerPage.tsx b/entrypoints/popup/pages/OpenUrlViewerPage.tsx index ad07ec3..affe80f 100644 --- a/entrypoints/popup/pages/OpenUrlViewerPage.tsx +++ b/entrypoints/popup/pages/OpenUrlViewerPage.tsx @@ -1,6 +1,7 @@ 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 { dashboardPageStyles } from '@/config/pageTheme'; // 只允许 HTTP/HTTPS 协议,阻止危险协议 const ALLOWED_PROTOCOLS = ['http:', 'https:']; @@ -8,6 +9,7 @@ const ALLOWED_PROTOCOLS = ['http:', 'https:']; export default function OpenUrlViewerPage() { const [currentUrl, setCurrentUrl] = useState(''); const [isLoaded, setIsLoaded] = useState(false); + const [iframeLoading, setIframeLoading] = useState(true); const [error, setError] = useState(null); // 验证 URL 是否安全 @@ -24,33 +26,59 @@ export default function OpenUrlViewerPage() { }; // 从存储加载当前选中的 URL - useEffect(() => { - const loadCurrentUrl = async () => { - try { - const saved = await storageUtil.get('openUrl/currentUrl', ''); - if (saved) { - const validationError = validateUrl(saved); - if (validationError) { - setError(validationError); - } else { - setCurrentUrl(saved); - setError(null); - } + const loadCurrentUrl = useCallback(async () => { + try { + const saved = await storageUtil.get('openUrl/currentUrl', ''); + if (saved) { + const validationError = validateUrl(saved); + if (validationError) { + setError(validationError); + } else { + setCurrentUrl(saved); + setError(null); + setIframeLoading(true); // 重置 iframe 加载状态 } - } catch (error) { - console.error('Failed to load current URL:', error); - setError('加载 URL 失败'); - } finally { - setIsLoaded(true); + } else { + setError(null); + setCurrentUrl(''); + } + } 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) { return ( - - Loading... + + ); } @@ -58,7 +86,10 @@ export default function OpenUrlViewerPage() { if (error) { return ( - {error} + + {error} + + 请返回 OpenUrl 页面选择有效的 URL。 ); } @@ -66,15 +97,46 @@ export default function OpenUrlViewerPage() { if (!currentUrl) { return ( - - 没有选中的 URL,请先在 OpenUrl 页面选择一个 URL 打开。 - + + 没有选中的 URL + + 请先在 OpenUrl 页面选择一个 URL 打开。 ); } return ( - + + {/* 加载状态指示器 */} + {iframeLoading && ( + + + + 加载中... + + + )}