diff --git a/AGENTS.md b/AGENTS.md index 82046db..ac91798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md -WXT 浏览器扩展项目 (React 19 + TypeScript + MUI v7)。 +WXT 浏览器扩展项目 (React 19 + TypeScript)。 ## 核心命令 @@ -9,6 +9,8 @@ npm run dev # Chrome 开发模式 (HMR) npm run dev:firefox # Firefox 开发模式 npm run build # Chrome 生产构建 npm run build:firefox # Firefox 生产构建 +npm run zip # 打包 Chrome 扩展 (.output/*.zip) +npm run zip:firefox # 打包 Firefox 扩展 npm run lint # ESLint (--max-warnings=0) npm run typecheck # tsc --noEmit npm run test # vitest run (单次执行) @@ -20,48 +22,60 @@ npm run test:coverage # 带覆盖率的测试 ## 验证流程 -CI 执行顺序: `lint → typecheck → test → build` (build 依赖前三者)。 +CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。 -Pre-commit hook (lint-staged) 顺序: +Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`): -1. `prettier --write` -2. `eslint --fix --max-warnings=0` -3. `tsc --noEmit` +- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored` → `prettier --write` +- 其他文件 (`*.{json,css,scss,md}`): `prettier --write` -提交前确保三者通过。 +提交前确保 `lint` 和 `typecheck` 通过。 + +> **注意**: Release 工作流 (`.github/workflows/release.yml`) 存在脚本名错误,typecheck 步骤使用了不存在的 +> `npm run compile`,正确命令应为 `npm run typecheck`。 + +## WXT 生成文件 + +- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。 +- 生产构建输出到 `.output/` 目录。 +- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。 ## 项目结构 ``` config/features.tsx # 功能定义(路由 + 元数据的单一事实来源) -config/pageTheme.ts # 页面级主题常量 -config/theme.ts # MUI 全局主题 entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts) pages/ # 功能页面组件 (懒加载) components/ # 可复用 UI 组件 providers/ # React Context (Router, Theme 等) +hooks/ # 自定义 React Hooks utils/ # 工具函数与服务抽象 types/ # TypeScript 类型声明 -i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json) +i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON) ``` ## 关键架构决策 -**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。 +**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` +渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。 -**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 Hook。 +**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 +Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。 **通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。 **路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。 +**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。 + ## 测试环境 - 环境: jsdom - 全局变量: `vitest/globals` (describe, it, expect 等无需导入) - Setup 文件: `vitest.setup.ts` 自动 mock: - - `chrome.*` API (storage, tabs, runtime, cookies 等) + - `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等) - `react-i18next` (返回 key 作为翻译) + - `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译) - `window.matchMedia` - 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}` @@ -70,14 +84,14 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json) - 命名空间: `common` (默认), `features` - 翻译键格式: `namespace:key` (如 `features:timestamp.title`) - 语言: `zh` (默认), `en` -- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` +- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON ## 新功能开发清单 1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型 2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置 3. 在 `pages/` 创建页面组件 (懒加载) -4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译 +4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON) 5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` 6. 添加对应的单元测试 @@ -85,14 +99,16 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json) - 禁止使用 `any` (测试文件除外) - 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`) -- 样式: 复杂页面样式放 `config/pageTheme.ts`,简单样式用 MUI `sx` prop -- 格式: Prettier (100 字符宽, 单引号, 尾逗号) +- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具) +- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行) +- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径) ## 技术栈版本 - WXT: ^0.20.26 - React: ^19.2.6 -- MUI: ^7.3.8 +- Tailwind CSS: ^3.4.19 +- shadcn/ui (基于 Radix UI + class-variance-authority) - TypeScript: ^5.9.3 - Vitest: ^4.1.7 - i18next: ^26.2.0 diff --git a/README.md b/README.md index 0841b9b..4c56054 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,41 @@ - **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示. - **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性. +### 🖱️ 右键恢复工具 + +- **当前页面检测**: 自动识别当前活动标签页的域名. +- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作. +- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态. + ### 🖼️ 二维码工具 - **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载. - **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容. +### 🔄 JSON 工具 + +- **差异比较**: 对比两段 JSON 数据,高亮展示差异. +- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML. + +### 🧰 Base64 转换器 + +- **文本编解码**: 支持文本内容的 Base64 编码与解码. +- **文件转换**: 支持文件与 Base64 字符串互转. +- **图像预览**: 支持图片 Base64 编码与实时预览. + +### 📝 Markdown ↔ HTML 转换 + +- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码. +- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式. + ## 技术栈 - **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/) - **前端**: React 19 + TypeScript -- **UI 组件**: Material UI (MUI) @7.x -- **样式**: Emotion (Styled Components) +- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库) +- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数 - **日期处理**: dayjs (集成 UTC 和 Timezone 插件) +- **国际化**: i18next + react-i18next - **通信**: @webext-core/messaging - **存储**: Chrome Storage API (类型安全封装) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) @@ -62,9 +85,8 @@ ```text ├── components/ # 可复用 React 组件 -├── config/ # 应用配置(路由、功能元数据、主题) -│ ├── features.tsx # 功能定义与路由映射 -│ └── pageTheme.ts # 各功能页面的视觉风格配置 +├── config/ # 应用配置 +│ └── features.tsx # 功能定义与路由映射 ├── entrypoints/ # 扩展程序入口点 │ ├── popup/ # 点击图标弹出的主界面 │ ├── options/ # 扩展程序设置页面 @@ -72,10 +94,13 @@ │ ├── background.ts # 后台 Service Worker │ └── content.ts # 网页注入脚本 ├── pages/ # 各功能模块的页面组件 -├── providers/ # 全局状态提供者 (Router, Snackbar 等) -├── types/ # TypeScript 类型声明 +├── providers/ # 全局状态提供者 (Router, Theme 等) +├── hooks/ # 自定义 React Hooks ├── utils/ # 工具函数与服务抽象 -├── public/ # 静态资源 (图标、 manifest 资源等) +├── types/ # TypeScript 类型声明 +├── lib/ # 通用工具函数 (cn, utils 等) +├── i18n/ # 国际化资源 +├── public/ # 静态资源 (图标、manifest 资源等) ├── wxt.config.ts # WXT 框架核心配置 └── package.json # 项目元数据与依赖管理 ``` @@ -89,15 +114,20 @@ ### 常用命令 -| 命令 | 说明 | -| ----------------------- | -------------------------------- | -| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) | -| `npm run dev:firefox` | 启动 Firefox 开发模式 | -| `npm run build` | 构建 Chrome 生产版本 | -| `npm run compile` | 执行 TypeScript 类型检查 | -| `npm run lint` | 执行 ESLint 代码规范检查 | -| `npm run test` | 运行单元测试 | -| `npm run test:coverage` | 生成测试覆盖率报告 | +| 命令 | 说明 | +| ----------------------- | --------------------------------- | +| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) | +| `npm run dev:firefox` | 启动 Firefox 开发模式 | +| `npm run build` | 构建 Chrome 生产版本 | +| `npm run build:firefox` | 构建 Firefox 生产版本 | +| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) | +| `npm run zip:firefox` | 打包 Firefox 扩展 | +| `npm run typecheck` | 执行 TypeScript 类型检查 | +| `npm run lint` | 执行 ESLint 代码规范检查 | +| `npm run test` | 运行单元测试 | +| `npm run test:coverage` | 生成测试覆盖率报告 | + +运行单个测试: `npx vitest run path/to/file.test.ts` ### 自动化流程 @@ -110,12 +140,14 @@ 本扩展根据功能需要申请了以下权限: -- `storage`: 存储用户设置和工具配置. +- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据. - `activeTab` & `tabs`: 获取当前页面 URL 及其元数据. -- `scripting`: 在网页中执行清理脚本. +- `scripting`: 在网页中执行清理和右键恢复脚本. - `cookies`: 管理和清理网站 Cookie. - `sidePanel`: 支持在浏览器侧边栏中运行. - `clipboardWrite`: 提供一键复制功能. +- `contextMenus`: 注册右键菜单,支持快捷操作. +- `alarms`: 管理后台定时任务(如清理后自动刷新). ## 浏览器支持 diff --git a/components/DecodeResultPaper.tsx b/components/DecodeResultPaper.tsx index 617ffb5..5c20dca 100644 --- a/components/DecodeResultPaper.tsx +++ b/components/DecodeResultPaper.tsx @@ -10,7 +10,7 @@ import { Download } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { formatFileSize } from '@/utils/base64Converter'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; interface DecodeResultPaperProps { /** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */ @@ -38,7 +38,7 @@ export default function DecodeResultPaper({ onDownload, children, }: DecodeResultPaperProps) { - const { t } = useTranslation('base64Converter'); + const { t } = useLazyTranslation('base64Converter'); return (
diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx deleted file mode 100644 index 3bd9fac..0000000 --- a/components/PageHeader.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { ReactNode, useMemo } from 'react'; -import { getEntryPointType } from '@/config/features'; -import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具 - -export interface PageHeaderProps extends React.HTMLAttributes { - /** 要显示的图标组件 */ - icon: ReactNode; - /** - * 图标的颜色,支持: - * 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐 - * 2. 原生颜色值 (如 '#3b82f6') - */ - iconColor?: string; - /** 主标题文本 */ - title: string; - /** 副标题文本(可选) */ - subtitle?: string; - /** 在标题右侧显示的徽章/标签组件(可选) */ - badge?: ReactNode; - /** 覆盖图标容器的类名 */ - iconClassName?: string; - /** 覆盖主标题的类名 */ - titleClassName?: string; - /** 覆盖副标题的类名 */ - subtitleClassName?: string; -} - -export default function PageHeader({ - icon, - iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6" - title, - subtitle, - badge, - iconClassName, - titleClassName, - subtitleClassName, - className, - ...props -}: PageHeaderProps) { - const entryPointType = useMemo(() => getEntryPointType(), []); - - // 扩展环境判断:如果是 popup 形式则不渲染头部 - if (entryPointType === 'popup') { - return null; - } - - // 判断传入的是否是 Hex/RGB 等原生颜色值 - const isRawColor = - iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl'); - - return ( -
- {/* 图标容器 */} -
- {/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */} -
{icon}
-
- - {/* 标题区域 */} -
- {/* 标题行(含徽章) */} -
-

- {title} -

- {badge &&
{badge}
} -
- - {/* 副标题 - 使用 p 标签(block)保证换行 */} - {subtitle && ( -

- {subtitle} -

- )} -
-
- ); -} diff --git a/components/__tests__/DecodeResultPaper.test.tsx b/components/__tests__/DecodeResultPaper.test.tsx index 7dbc74f..a999307 100644 --- a/components/__tests__/DecodeResultPaper.test.tsx +++ b/components/__tests__/DecodeResultPaper.test.tsx @@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => { it('应渲染下载按钮', () => { render(); - expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument(); }); it('应渲染 children 内容', () => { @@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => { it('点击下载按钮时应调用 onDownload', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'download' })); + fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' })); expect(defaultProps.onDownload).toHaveBeenCalledTimes(1); }); }); @@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => { describe('按钮状态', () => { it('文件名为空时下载按钮应禁用', () => { render(); - expect(screen.getByRole('button', { name: 'download' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled(); }); it('文件名不为空时下载按钮应启用', () => { render(); - expect(screen.getByRole('button', { name: 'download' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled(); }); }); }); diff --git a/components/__tests__/PageHeader.test.tsx b/components/__tests__/PageHeader.test.tsx deleted file mode 100644 index 0b4a8f0..0000000 --- a/components/__tests__/PageHeader.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import PageHeader, { type PageHeaderProps } from '@/components/PageHeader'; - -vi.mock('@/config/features', () => ({ - getEntryPointType: vi.fn(() => 'sidepanel'), -})); - -describe('PageHeader 组件系统', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.resetAllMocks(); - }); - - const defaultProps: PageHeaderProps = { - icon: , - title: '时间戳转换', - subtitle: 'Unix 毫秒数转换与格式化', - }; - - describe('PageHeader UI 渲染', () => { - it('应渲染页面标题栏&副标题', () => { - render(); - expect(screen.getByText('时间戳转换')).toBeInTheDocument(); - expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument(); - }); - - it('应渲染图标', () => { - render(); - expect(screen.getByTestId('test-icon')).toBeInTheDocument(); - }); - - it('应渲染自定义图标&图标颜色', () => { - render( - X} - iconColor="#FF0000" - />, - ); - expect(screen.getByTestId('custom-icon')).toBeInTheDocument(); - }); - - it('应默认使用蓝色作为 primary 色', () => { - render(); - const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement; - expect(iconContainer).toHaveClass('text-blue-500'); - }); - - it('应渲染 badge 组件', () => { - const badge = New; - render(); - expect(screen.getByTestId('test-badge')).toBeInTheDocument(); - }); - - it('应支持自定义 iconClassName', () => { - render(); - const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement; - expect(iconContainer).toHaveClass('custom-icon-class'); - }); - - it('应支持自定义 titleClassName', () => { - render(); - const title = screen.getByText('时间戳转换'); - expect(title).toHaveClass('custom-title-class'); - }); - - it('应支持自定义 subtitleClassName', () => { - render(); - const subtitle = screen.getByText('Unix 毫秒数转换与格式化'); - expect(subtitle).toHaveClass('custom-subtitle-class'); - }); - - it('应支持自定义 className', () => { - const { container } = render(); - const outerElement = container.firstChild; - expect(outerElement).toHaveClass('custom-page-header'); - }); - - it('无副标题时不渲染副标题区域', () => { - const { container } = render(); - const subtitles = container.querySelectorAll('.text-muted-foreground'); - expect(subtitles.length).toBe(0); - }); - }); - - describe('PageHeader 入口点隐藏', () => { - it('popup 模式下应返回 null', async () => { - const { getEntryPointType } = await import('@/config/features'); - vi.mocked(getEntryPointType).mockReturnValue('popup'); - - const { container } = render(); - expect(container.innerHTML).toBe(''); - }); - }); -}); diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts index 5ddcd27..ac6e29a 100644 --- a/config/__tests__/features.test.ts +++ b/config/__tests__/features.test.ts @@ -9,8 +9,8 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 10 features defined', () => { - expect(FEATURES).toHaveLength(10); + it('should have 11 features defined', () => { + expect(FEATURES).toHaveLength(11); }); it('should have all required properties for each feature', () => { @@ -95,7 +95,7 @@ describe('features', () => { describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(10); + expect(allKeys).toHaveLength(11); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); @@ -106,6 +106,7 @@ describe('features', () => { expect(allKeys).toContain('base64Converter'); expect(allKeys).toContain('markdownToHtml'); expect(allKeys).toContain('htmlToMarkdown'); + expect(allKeys).toContain('rightClickRestorer'); }); }); @@ -122,9 +123,9 @@ describe('features', () => { expect(pageOrder).toContain('qrCode'); }); - it('should have 9 items in page order', () => { + it('should have 10 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(9); + expect(pageOrder).toHaveLength(10); }); }); }); diff --git a/config/features.tsx b/config/features.tsx index 3f2def8..3dc631e 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -11,6 +11,7 @@ import { ArrowLeftRight, Code, File, + MousePointerClick, } from 'lucide-react'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; @@ -26,6 +27,7 @@ const JsonToolsPage = lazy(() => import('@/pages/JsonTools')); const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter')); const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml')); const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown')); +const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer')); export interface FeatureConfig { key: PageType; @@ -170,6 +172,19 @@ export const FEATURES: FeatureConfig[] = [ tab: HtmlToMarkdownPage, }, }, + { + key: 'rightClickRestorer', + labelKey: 'features:rightClickRestorer.title', + descriptionKey: 'features:rightClickRestorer.description', + themeColorKey: 'success', + icon: MousePointerClick, + defaultVisible: true, + components: { + popup: RightClickRestorerPage, + sidepanel: RightClickRestorerPage, + tab: RightClickRestorerPage, + }, + }, ]; export function getFeatureByKey(key: PageType): FeatureConfig | undefined { diff --git a/config/pageTheme.ts b/config/pageTheme.ts deleted file mode 100644 index f3f9b80..0000000 --- a/config/pageTheme.ts +++ /dev/null @@ -1,56 +0,0 @@ -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: '#1976d2', - primaryDark: '#1565c0', - primaryLight: '#42a5f5', - success: '#2e7d32', - successDark: '#1b5e20', - successLight: '#4caf50', - warning: '#e65100', - warningDark: '#bf360c', - warningLight: '#ff9800', - error: '#c62828', - errorDark: '#b71c1c', - errorLight: '#f44336', - purple: '#6a1b9a', - purpleDark: '#4a148c', - purpleLight: '#9c27b0', - indigo: '#303f9f', - indigoDark: '#1a237e', - indigoLight: '#7986cb', - white: '#FFFFFF', - black: '#000000', -} as const; - -export const STATUS_COLORS = { - success: THEME_COLORS.success, - warning: THEME_COLORS.warning, - error: THEME_COLORS.error, - info: THEME_COLORS.primary, -} as const; - -export const timestampPageStyles = { - primaryColor: THEME_COLORS.primary, -}; - -export const storageCleanerPageStyles = { - warningColor: THEME_COLORS.warning, -}; - -export const qrCodePageStyles = { - primaryColor: THEME_COLORS.success, -}; - -export const textStatisticsPageStyles = { - primaryColor: THEME_COLORS.purple, -}; - -export const base64ConverterPageStyles = { - primaryColor: THEME_COLORS.indigo, -}; diff --git a/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md b/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md deleted file mode 100644 index e90df6d..0000000 --- a/docs/superpowers/specs/2026-05-13-page-error-boundary-design.md +++ /dev/null @@ -1,135 +0,0 @@ -# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档 - -- 日期:2026-05-13 -- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离 - -## 背景与目标 - -当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。 - -问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。 - -目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。 - -## 设计概要 - -新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer` 的 `Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。 - -## 组件设计 - -### `components/PageErrorBoundary.tsx`(新增) - -复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异: - -- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构: - - 图标 (`ErrorOutlineIcon`) - - 标题:"该页面加载失败" - - 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。" - - 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动) - - 主操作按钮:"重试"(`RefreshIcon`)—— 重置内部 state,让子树重新挂载 -- **`resetKey` prop**:可选;当 `resetKey` 在 `componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作 -- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报 - -接口: -```ts -interface PageErrorBoundaryProps { - children: ReactNode; - resetKey?: string | number; // 变化时自动重置 -} -``` - -### `components/ErrorBoundary.tsx`(不变) - -保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。 - -## 集成点 - -### `components/RouterContainer.tsx`(修改) - -在 `Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`: - -```tsx -}> - - {Component && } - - -``` - -说明: -- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误 -- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预 -- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义 - -### `entrypoints/options/App.tsx`(修改) - -将顶层 `` 替换为 ``。options 是单页应用,统一使用页面级错误卡片即可。 - -### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变) - -保留外层 `` 包裹 ``,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职: - -- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域 -- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底 - -## 数据流 / 错误处理 - -### 捕获路径 -- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获 -- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获 -- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理) - -### 恢复路径 -- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染 -- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`) -- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具 - -### 日志 -沿用 `console.error`;不引入 Sentry / 外部上报。 - -## 测试策略 - -### 新增 `components/__tests__/PageErrorBoundary.test.tsx` - -1. 正常渲染:子组件正常渲染时,输出原始 children -2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息) -3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容 -4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children -5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置) -6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见 - -### 新增 `components/__tests__/RouterContainer.test.tsx` - -- 通过 mock `useRouter` 与 `FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在 -- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染 - -### 不变 -- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变) - -### 风格 -遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react` 的 `render` + `screen.getByText` 断言。 - -## 文件清单 - -**新增:** -- `components/PageErrorBoundary.tsx` -- `components/__tests__/PageErrorBoundary.test.tsx` -- `components/__tests__/RouterContainer.test.tsx` - -**修改:** -- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}` -- `entrypoints/options/App.tsx` — 将 `` 替换为 `` - -**不变:** -- `components/ErrorBoundary.tsx` -- `components/__tests__/ErrorBoundary.test.tsx` -- `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` -- `config/features.tsx` - -## 验收标准 - -1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击 -2. 在错误状态下切换到其他工具,新页面能正常加载与显示 -3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载 -4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误 -5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile`、`npm run lint`、`npm run test` 均通过 diff --git a/entrypoints/background.ts b/entrypoints/background.ts index cf4e4c2..fcfe6eb 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -5,12 +5,12 @@ import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMen import { saveContextMenuData } from '@/utils/useContextMenuData'; export default defineBackground(() => { - // 1. 扩展初次安装或更新时,注册右键上下文大闸 + // 1. 扩展初次安装或更新时,注册右键上下文菜单 browser.runtime.onInstalled.addListener(() => { createAllContextMenus(); }); - // 2. 右键点击中央中枢路由 + // 2. 右键点击路由 browser.contextMenus.onClicked.addListener(async (info, _tab) => { const result = parseContextMenuClick(info.menuItemId as string, info); @@ -24,12 +24,10 @@ export default defineBackground(() => { const { featureKey, payload } = result.data; try { - // 检查侧边栏(Side Panel)的挂载激活状态 const sidePanelState = await browser.storage.local.get('sidePanelOpen'); const isSidePanelOpen = sidePanelState.sidePanelOpen === true; if (isSidePanelOpen) { - // 如果侧边栏正开着,利用高性能管道直发 await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload }); return; } @@ -37,18 +35,13 @@ export default defineBackground(() => { console.debug('[Context Menu] Side panel pipeline is not available:', err); } - // 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取 await saveContextMenuData({ featureKey, payload }); - // 打开 popup 弹窗 try { await browser.action.openPopup(); } catch (err) { - // 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁! - // 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标, - // 数据依旧完好如初,完美契合了你的设计注释! console.warn( - '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留在内存中:', + '[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:', err, ); } @@ -65,7 +58,96 @@ export default defineBackground(() => { } }); - // 💡 3. 异步刷新请求监听 + // 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行) + onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => { + const sender = message.sender as chrome.runtime.MessageSender | undefined; + const tabId = sender?.tab?.id; + + if (!tabId) { + console.warn('[RightClickRestorer] Injection request missing tabId'); + return { success: false, message: 'Missing tabId' }; + } + + try { + await browser.scripting.executeScript({ + target: { tabId }, + func: () => { + 'use strict'; + const w = window as unknown as Record; + if (w.__testingToolsRightClickPatched) return; + w.__testingToolsRightClickPatched = true; + + const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart']; + + /* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */ + const _origPreventDefault = MouseEvent.prototype.preventDefault; + Object.defineProperty(MouseEvent.prototype, 'preventDefault', { + value: function (this: MouseEvent) { + const t = this.type; + if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) { + return; + } + return _origPreventDefault.call(this); + }, + writable: true, + configurable: true, + }); + + /* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */ + const _origStopPropagation = Event.prototype.stopPropagation; + Object.defineProperty(Event.prototype, 'stopPropagation', { + value: function (this: Event) { + if (PROTECTED.includes(this.type)) return; + return _origStopPropagation.call(this); + }, + writable: true, + configurable: true, + }); + + const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation; + Object.defineProperty(Event.prototype, 'stopImmediatePropagation', { + value: function (this: Event) { + if (PROTECTED.includes(this.type)) return; + return _origStopImmediatePropagation.call(this); + }, + writable: true, + configurable: true, + }); + + /* 3. 拦截 document.oncontextmenu(处理 return false 方式) */ + let _docOnContextMenu: unknown = null; + Object.defineProperty(document, 'oncontextmenu', { + get() { + return _docOnContextMenu; + }, + set(fn: unknown) { + if (typeof fn === 'function') { + _docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) { + const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call( + this, + e, + ); + return r === false ? true : r; + }; + } else { + _docOnContextMenu = fn; + } + }, + configurable: true, + }); + }, + world: 'MAIN', + }); + + return { success: true }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[RightClickRestorer] executeScript failed:', errorMsg); + return { success: false, message: errorMsg }; + } + }); + + // 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失) onMessage(MessageAction.RELOAD_TAB, async (message) => { const { tabId, delay = 0 } = message.data; @@ -75,28 +157,33 @@ export default defineBackground(() => { }); }; - // 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御 - // 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动 - if (delay > 0 && delay < 1000) { - setTimeout(executeReload, delay); - } else if (delay >= 1000) { - const alarmName = `reload-tab-${tabId}-${Date.now()}`; - - // 创建一个临时的一次性 Alarm 闹钟 - await browser.alarms.create(alarmName, { when: Date.now() + delay }); - - // 动态注册一个一次性的生命周期续航守卫 - const alarmListener = (alarm: { name: string }) => { - if (alarm.name === alarmName) { - executeReload(); - browser.alarms.onAlarm.removeListener(alarmListener); - } - }; - browser.alarms.onAlarm.addListener(alarmListener); - } else { + if (delay <= 0) { executeReload(); + return { success: true }; } - return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' }; + const alarmName = `reload-tab-${tabId}-${Date.now()}`; + + // 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响) + await browser.alarms.create(alarmName, { when: Date.now() + delay }); + + // 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm + const cleanupTimeout = setTimeout(() => { + browser.alarms.onAlarm.removeListener(alarmListener); + browser.alarms.clear(alarmName).catch(() => {}); + }, delay + 5000); + + const alarmListener = (alarm: { name: string }) => { + if (alarm.name !== alarmName) return; + + clearTimeout(cleanupTimeout); + executeReload(); + browser.alarms.onAlarm.removeListener(alarmListener); + browser.alarms.clear(alarmName).catch(() => {}); + }; + + browser.alarms.onAlarm.addListener(alarmListener); + + return { success: true }; }); }); diff --git a/entrypoints/content/contextMenuHandler.ts b/entrypoints/content/contextMenuHandler.ts index 249c1ff..651d0c0 100644 --- a/entrypoints/content/contextMenuHandler.ts +++ b/entrypoints/content/contextMenuHandler.ts @@ -3,7 +3,6 @@ import { MessageAction, onMessage } from '@/utils/messages'; import { getTextStats } from '@/utils/textStatistics'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; -// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文 function getI18nText(key: string, fallback: string): string { if (typeof chrome !== 'undefined' && chrome.i18n) { return chrome.i18n.getMessage(key) || fallback; @@ -39,20 +38,16 @@ function convertTimestamp(input: string): string { let lastClickX = 0; let lastClickY = 0; -// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标 document.addEventListener( 'contextmenu', (e) => { lastClickX = e.clientX; lastClickY = e.clientY; }, - { capture: true, passive: true }, // 优化滚动与捕获性能 + { capture: true, passive: true }, ); export function initContextMenuHandler(): void { - // 💡 2. 全局自净化大闸(Global Auto-Purge Grid): - // 当用户在网页上进行左键点击、滚动视视口、或调整大小时, - // 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣! const dismissPopover = (): void => { hidePopover(); }; diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 121eeb9..346be9f 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -1,5 +1,5 @@ import { SyntheticEvent, useEffect, useMemo, useState } from 'react'; -import { GripVertical, RefreshCw, Settings } from 'lucide-react'; +import { GripVertical, RefreshCw } from 'lucide-react'; import { closestCenter, DndContext, @@ -28,7 +28,6 @@ import { } from '@/config/features'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import PageErrorBoundary from '@/components/PageErrorBoundary'; -import PageHeader from '@/components/PageHeader'; import { useTranslation } from 'react-i18next'; const PALETTE_COLORS: Record = { @@ -317,12 +316,6 @@ export default function App() { {/* 顶部标题与导航栏 */}
- } - iconColor="#1976d2" - title="应用设置" - subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序" - /> {/* Tab 与恢复按钮同行 */}
diff --git a/entrypoints/rightClickRestorer.content.ts b/entrypoints/rightClickRestorer.content.ts new file mode 100644 index 0000000..5d59201 --- /dev/null +++ b/entrypoints/rightClickRestorer.content.ts @@ -0,0 +1,194 @@ +import { MessageAction, onMessage, sendMessage } from '@/utils/messages'; + +const BADGE_ID = 'testing-tools-right-click-restorer-badge'; +const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style'; + +let isRestored = false; + +function updateBadge(): void { + const badge = document.getElementById(BADGE_ID); + if (badge) { + badge.style.opacity = isRestored ? '1' : '0'; + } +} + +function createBadge(): void { + if (document.getElementById(BADGE_ID)) return; + + if (!document.getElementById(BADGE_STYLE_ID)) { + const style = document.createElement('style'); + style.id = BADGE_STYLE_ID; + style.textContent = ` + #${BADGE_ID} { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 2147483646; + padding: 6px 12px; + background: #2e7d32; + color: #ffffff; + border-radius: 20px; + font-size: 12px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); + opacity: 0; + transition: opacity 0.2s ease-out; + pointer-events: none; + user-select: none; + } + @media (prefers-color-scheme: dark) { + #${BADGE_ID} { + background: #4caf50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + } + } + `; + document.head.appendChild(style); + } + + const badge = document.createElement('div'); + badge.id = BADGE_ID; + badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501'; + document.body.appendChild(badge); +} + +/* ======================== Main World 注入 ======================== */ + +async function injectMainWorldScript(): Promise { + try { + const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT); + if (!response.success) { + console.error('[RightClickRestorer] Background injection failed:', response.message); + } + } catch (err) { + console.error('[RightClickRestorer] Failed to request main world injection:', err); + } +} + +/* ======================== Isolated World 事件拦截 ======================== */ + +function installEventIntercepts(): void { + window.addEventListener( + 'contextmenu', + (e) => { + if (!isRestored) return; + e.stopPropagation(); + }, + true, + ); + + // 部分网站在 mousedown 阶段阻止 contextmenu + window.addEventListener( + 'mousedown', + (e) => { + if (!isRestored) return; + if (e.button === 2 || (e.buttons & 2) !== 0) { + e.stopPropagation(); + } + }, + true, + ); +} + +/* ======================== 遮罩层穿透 ======================== */ + +const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']); + +function initMousePenetration(): void { + window.addEventListener( + 'mousedown', + (e) => { + if (!isRestored || e.button !== 2) return; + + const elements = document.elementsFromPoint(e.clientX, e.clientY); + if (!elements.length) return; + + let targetMedia: HTMLElement | null = null; + for (const el of elements) { + if (MEDIA_TAGS.has(el.tagName)) { + targetMedia = el as HTMLElement; + break; + } + } + + if (!targetMedia) return; + + const modified: Array<{ el: HTMLElement; original: string | null }> = []; + let foundMedia = false; + + for (const el of elements) { + const htmlEl = el as HTMLElement; + + if (el === targetMedia) { + foundMedia = true; + const original = htmlEl.style.pointerEvents || null; + htmlEl.style.setProperty('pointer-events', 'all', 'important'); + modified.push({ el: htmlEl, original }); + continue; + } + + if (!foundMedia) { + const original = htmlEl.style.pointerEvents || null; + htmlEl.style.setProperty('pointer-events', 'none', 'important'); + modified.push({ el: htmlEl, original }); + } + } + + setTimeout(() => { + for (const { el, original } of modified) { + if (original === null || original === '') { + el.style.removeProperty('pointer-events'); + } else { + el.style.pointerEvents = original; + } + } + }, 300); + }, + true, + ); +} + +/* ======================== 激活保护 ======================== */ + +async function activateProtection(): Promise { + if (isRestored) return; + + await injectMainWorldScript(); + + // 再次检查,防止并发调用导致重复注册 + if (isRestored) return; + + installEventIntercepts(); + initMousePenetration(); + + isRestored = true; + updateBadge(); +} + +/* ======================== 消息通信 ======================== */ + +function initMessaging(): void { + onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => { + await activateProtection(); + return { success: true, restored: isRestored }; + }); + + onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => { + return { success: true, restored: isRestored }; + }); +} + +/* ======================== 入口 ======================== */ + +export default defineContentScript({ + matches: [''], + runAt: 'document_start', + main() { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', createBadge, { once: true }); + } else { + createBadge(); + } + initMessaging(); + }, +}); diff --git a/i18n/locales/en/features.json b/i18n/locales/en/features.json index b33b26c..96dc086 100644 --- a/i18n/locales/en/features.json +++ b/i18n/locales/en/features.json @@ -37,5 +37,9 @@ "htmlToMarkdown": { "title": "HTML to Markdown", "description": "Real-time HTML conversion and Markdown preview" + }, + "rightClickRestorer": { + "title": "Right Click Restorer", + "description": "Detect and restore disabled browser right-click menus" } } diff --git a/i18n/locales/en/rightClickRestorer.json b/i18n/locales/en/rightClickRestorer.json new file mode 100644 index 0000000..897a416 --- /dev/null +++ b/i18n/locales/en/rightClickRestorer.json @@ -0,0 +1,13 @@ +{ + "title": "Right Click Restorer", + "description": "Detect and restore disabled browser right-click menus", + "loading": "Loading...", + "currentDomain": "Current Domain", + "statusLocked": "Locked", + "statusUnlocked": "Unlocked", + "unsupported": "Unsupported", + "unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.", + "unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.", + "unlockBtn": "Unlock Right Click", + "alreadyUnlocked": "Right Click Unlocked" +} diff --git a/i18n/locales/zh/features.json b/i18n/locales/zh/features.json index 660c45b..3cbfe0b 100644 --- a/i18n/locales/zh/features.json +++ b/i18n/locales/zh/features.json @@ -37,5 +37,9 @@ "htmlToMarkdown": { "title": "HTML 转 Markdown", "description": "实时 HTML 转换与 Markdown 预览" + }, + "rightClickRestorer": { + "title": "右键恢复", + "description": "检测并恢复被网站禁用的浏览器右键菜单" } } diff --git a/i18n/locales/zh/rightClickRestorer.json b/i18n/locales/zh/rightClickRestorer.json new file mode 100644 index 0000000..4c6bd34 --- /dev/null +++ b/i18n/locales/zh/rightClickRestorer.json @@ -0,0 +1,13 @@ +{ + "title": "右键恢复", + "description": "检测并恢复被网站禁用的浏览器右键菜单", + "loading": "正在加载...", + "currentDomain": "当前域名", + "statusLocked": "未解锁", + "statusUnlocked": "已解锁", + "unsupported": "不支持", + "unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。", + "unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。", + "unlockBtn": "解锁当前网站右键", + "alreadyUnlocked": "右键已解锁" +} diff --git a/pages/Base64Converter/Base64ConverterSection.tsx b/pages/Base64Converter/Base64ConverterSection.tsx index 5543201..3cc0d4e 100644 --- a/pages/Base64Converter/Base64ConverterSection.tsx +++ b/pages/Base64Converter/Base64ConverterSection.tsx @@ -1,6 +1,6 @@ import { Image as ImageIcon, Trash2, Upload } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from '@/components/DecodeResultPaper'; import { Button } from '@/components/ui/button'; @@ -19,7 +19,7 @@ interface Base64ConverterSectionProps { } export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) { - const { t } = useTranslation('base64Converter'); + const { t } = useLazyTranslation('base64Converter'); const [direction, setDirection] = useStorageState( `base64Converter/${mode}Mode/direction`, diff --git a/pages/Base64Converter/FileMode.tsx b/pages/Base64Converter/FileMode.tsx index 5cf89a1..c331fac 100644 --- a/pages/Base64Converter/FileMode.tsx +++ b/pages/Base64Converter/FileMode.tsx @@ -1,6 +1,6 @@ import { Trash2, Upload } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from '@/components/DecodeResultPaper'; import { Button } from '@/components/ui/button'; @@ -15,7 +15,7 @@ const isValidDirection = (val: unknown): val is Base64ConvertDirection => val === 'encode' || val === 'decode'; export default function FileMode() { - const { t } = useTranslation('base64Converter'); + const { t } = useLazyTranslation('base64Converter'); const [direction, setDirection] = useStorageState( 'base64Converter/fileMode/direction', 'encode', diff --git a/pages/Base64Converter/ImageMode.tsx b/pages/Base64Converter/ImageMode.tsx index 7c5cd00..a61285a 100644 --- a/pages/Base64Converter/ImageMode.tsx +++ b/pages/Base64Converter/ImageMode.tsx @@ -1,6 +1,6 @@ import { Image as ImageIcon, Trash2 } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import CopyButton from '@/components/CopyButton'; import DecodeResultPaper from '@/components/DecodeResultPaper'; import { Button } from '@/components/ui/button'; @@ -15,7 +15,7 @@ const isValidDirection = (val: unknown): val is Base64ConvertDirection => val === 'encode' || val === 'decode'; export default function ImageMode() { - const { t } = useTranslation('base64Converter'); + const { t } = useLazyTranslation('base64Converter'); const [direction, setDirection] = useStorageState( 'base64Converter/imageMode/direction', 'encode', diff --git a/pages/Base64Converter/TextMode.tsx b/pages/Base64Converter/TextMode.tsx index c78960d..3c34e72 100644 --- a/pages/Base64Converter/TextMode.tsx +++ b/pages/Base64Converter/TextMode.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import TextInputArea from '@/components/TextInputArea'; -import { useTranslation } from 'react-i18next'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; import CopyButton from '@/components/CopyButton'; import { base64ToText, textToBase64 } from '@/utils/base64Converter'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; @@ -20,7 +20,7 @@ interface TextModeProps { } export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) { - const { t } = useTranslation('base64Converter'); + const { t } = useLazyTranslation('base64Converter'); // 1. 纯净的核心源状态机:只保留输入源和转换方向 const [input, setInput] = useState(''); diff --git a/pages/Base64Converter/__tests__/FileMode.test.tsx b/pages/Base64Converter/__tests__/FileMode.test.tsx index 3e9f91a..4af2760 100644 --- a/pages/Base64Converter/__tests__/FileMode.test.tsx +++ b/pages/Base64Converter/__tests__/FileMode.test.tsx @@ -28,8 +28,8 @@ describe('FileMode', () => { it('应该渲染文件上传区域', async () => { render(); await waitForStorageReady(); - expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument(); - expect(screen.getByText('maxFileSize')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:maxFileSize')).toBeInTheDocument(); }); it('应该处理有效的文件选择', async () => { @@ -44,7 +44,7 @@ describe('FileMode', () => { await waitFor(() => { expect(screen.getByText('test.txt')).toBeInTheDocument(); - expect(screen.getByText('base64Output')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument(); }); }); @@ -60,7 +60,7 @@ describe('FileMode', () => { fireEvent.change(hiddenInput, { target: { files: [file] } }); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded'); + expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded'); }); }); @@ -76,11 +76,11 @@ describe('FileMode', () => { expect(screen.getByText('test.txt')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('clear')); + fireEvent.click(screen.getByText('base64Converter:clear')); await waitFor(() => { expect(screen.queryByText('test.txt')).not.toBeInTheDocument(); - expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument(); }); }); @@ -108,8 +108,8 @@ describe('FileMode', () => { fireEvent.change(hiddenInput, { target: { files: [file] } }); await waitFor(() => { - expect(screen.getByText(/originalSize/)).toBeInTheDocument(); - expect(screen.getByText(/encodedSize/)).toBeInTheDocument(); + expect(screen.getByText(/base64Converter:originalSize/)).toBeInTheDocument(); + expect(screen.getByText(/base64Converter:encodedSize/)).toBeInTheDocument(); }); }); @@ -129,23 +129,25 @@ describe('FileMode', () => { it('应该渲染 encode/decode 切换按钮', () => { render(); - expect(screen.getByText('encode')).toBeInTheDocument(); - expect(screen.getByText('decode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:encode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:decode')).toBeInTheDocument(); }); it('切到 decode 应该显示 Base64 输入框', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); - expect(await screen.findByPlaceholderText('decodeBase64Placeholder')).toBeInTheDocument(); + fireEvent.click(screen.getByText('base64Converter:decode')); + expect( + await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'), + ).toBeInTheDocument(); }); it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); act(() => { @@ -153,7 +155,7 @@ describe('FileMode', () => { }); await waitFor(() => { - expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument(); expect(screen.getByText(/application\/pdf/)).toBeInTheDocument(); expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument(); }); @@ -162,9 +164,9 @@ describe('FileMode', () => { it('解码后的文件名应该可编辑', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); act(() => { @@ -179,24 +181,24 @@ describe('FileMode', () => { it('解码后应该显示下载按钮', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); act(() => { vi.advanceTimersByTime(250); }); - expect(await screen.findByText('download')).toBeInTheDocument(); + expect(await screen.findByText('base64Converter:download')).toBeInTheDocument(); }); it('解码非法 Base64 应该显示 invalidBase64 错误', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: '!!!not base64' } }); act(() => { @@ -204,16 +206,16 @@ describe('FileMode', () => { }); await waitFor(() => { - expect(screen.getByText('invalidBase64')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument(); }); }); it('切换方向时应该清空解码状态', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'JVBERi0K' } }); act(() => { @@ -221,10 +223,10 @@ describe('FileMode', () => { }); await waitFor(() => { - expect(screen.getByText('decodedFileOutput')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('encode')); + fireEvent.click(screen.getByText('base64Converter:encode')); await waitFor(() => { expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument(); diff --git a/pages/Base64Converter/__tests__/ImageMode.test.tsx b/pages/Base64Converter/__tests__/ImageMode.test.tsx index e8ccfc7..440dbe7 100644 --- a/pages/Base64Converter/__tests__/ImageMode.test.tsx +++ b/pages/Base64Converter/__tests__/ImageMode.test.tsx @@ -26,8 +26,8 @@ describe('ImageMode', () => { it('应该渲染图像上传区域', async () => { render(); await waitForStorageReady(); - expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument(); - expect(screen.getByText('supportedFormats')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:supportedFormats')).toBeInTheDocument(); }); it('应该接受有效的图像文件', async () => { @@ -40,7 +40,7 @@ describe('ImageMode', () => { await waitFor(() => { expect(screen.getByText('test.png')).toBeInTheDocument(); - expect(screen.getByText('base64Output')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument(); }); }); @@ -53,7 +53,7 @@ describe('ImageMode', () => { fireEvent.change(hiddenInput, { target: { files: [file] } }); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('unsupportedImageType'); + expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:unsupportedImageType'); }); }); @@ -68,7 +68,7 @@ describe('ImageMode', () => { fireEvent.change(hiddenInput, { target: { files: [file] } }); await waitFor(() => { - expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded'); + expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded'); }); }); @@ -98,11 +98,11 @@ describe('ImageMode', () => { expect(screen.getByText('test.png')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('clear')); + fireEvent.click(screen.getByText('base64Converter:clear')); await waitFor(() => { expect(screen.queryByText('test.png')).not.toBeInTheDocument(); - expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument(); }); }); @@ -124,16 +124,16 @@ describe('ImageMode', () => { it('应该渲染 encode/decode 切换按钮', async () => { render(); await waitForStorageReady(); - expect(screen.getByText('encode')).toBeInTheDocument(); - expect(screen.getByText('decode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:encode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:decode')).toBeInTheDocument(); }); it('解码 PNG Base64 后应该显示图像预览', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); act(() => { @@ -141,7 +141,7 @@ describe('ImageMode', () => { }); await waitFor(() => { - expect(screen.getByText('decodedImageOutput')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument(); const img = screen.getByAltText('decoded preview'); expect(img).toBeInTheDocument(); expect(img.tagName.toLowerCase()).toBe('img'); @@ -151,9 +151,9 @@ describe('ImageMode', () => { it('解码后默认文件名应该为 decoded.png', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); act(() => { @@ -166,9 +166,9 @@ describe('ImageMode', () => { it('解码非法 Base64 应该显示 invalidBase64 错误', async () => { render(); await waitForStorageReady(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = await screen.findByPlaceholderText('decodeBase64Placeholder'); + const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'); fireEvent.change(input, { target: { value: '!!!not base64' } }); act(() => { @@ -176,7 +176,7 @@ describe('ImageMode', () => { }); await waitFor(() => { - expect(screen.getByText('invalidBase64')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument(); }); }); }); diff --git a/pages/Base64Converter/__tests__/TextMode.test.tsx b/pages/Base64Converter/__tests__/TextMode.test.tsx index cae191a..c5d86d3 100644 --- a/pages/Base64Converter/__tests__/TextMode.test.tsx +++ b/pages/Base64Converter/__tests__/TextMode.test.tsx @@ -18,18 +18,18 @@ describe('TextMode', () => { it('应该渲染编码/解码切换按钮', () => { render(); - expect(screen.getByText('encode')).toBeInTheDocument(); - expect(screen.getByText('decode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:encode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:decode')).toBeInTheDocument(); }); it('应该渲染输入框和转换按钮', () => { render(); - expect(screen.getByPlaceholderText('textInputPlaceholder')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('base64Converter:textInputPlaceholder')).toBeInTheDocument(); }); it('应该将文本编码为 Base64', async () => { render(); - const input = screen.getByPlaceholderText('textInputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder'); fireEvent.change(input, { target: { value: 'Hello' } }); act(() => { @@ -37,7 +37,7 @@ describe('TextMode', () => { }); await waitFor(() => { - expect(screen.getByText('base64Output')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument(); }); expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8='); }); @@ -46,9 +46,9 @@ describe('TextMode', () => { render(); // 切换到解码模式 - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = screen.getByPlaceholderText('base64InputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); act(() => { @@ -56,7 +56,7 @@ describe('TextMode', () => { }); await waitFor(() => { - expect(screen.getByText('textOutput')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument(); }); expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello'); }); @@ -65,9 +65,9 @@ describe('TextMode', () => { render(); // 切换到解码模式 - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = screen.getByPlaceholderText('base64InputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'invalid!!!' } }); act(() => { @@ -75,7 +75,7 @@ describe('TextMode', () => { }); await waitFor(() => { - expect(screen.getByText('invalidBase64')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument(); }); }); @@ -83,7 +83,7 @@ describe('TextMode', () => { render(); // 先编码 - const input = screen.getByPlaceholderText('textInputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder'); fireEvent.change(input, { target: { value: 'Hello' } }); act(() => { @@ -95,7 +95,7 @@ describe('TextMode', () => { }); // 切换方向 - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); // 输出应该被清除 await waitFor(() => { @@ -106,7 +106,7 @@ describe('TextMode', () => { it('点击清除按钮应该清空所有内容', async () => { render(); - const input = screen.getByPlaceholderText('textInputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder'); fireEvent.change(input, { target: { value: 'Hello' } }); act(() => { @@ -128,48 +128,48 @@ describe('TextMode', () => { it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => { render(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = screen.getByPlaceholderText('base64InputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, }); - expect(screen.getByText('imageDataUriHint')).toBeInTheDocument(); - expect(screen.getByText('switchToImageMode')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:imageDataUriHint')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:switchToImageMode')).toBeInTheDocument(); }); it('粘贴非图片 data URI 时不应该显示图像模式提示', () => { render(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = screen.getByPlaceholderText('base64InputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'SGVsbG8=' } }); - expect(screen.queryByText('imageDataUriHint')).not.toBeInTheDocument(); + expect(screen.queryByText('base64Converter:imageDataUriHint')).not.toBeInTheDocument(); }); it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => { const onSwitch = vi.fn(); render(); - fireEvent.click(screen.getByText('decode')); - const input = screen.getByPlaceholderText('base64InputPlaceholder'); + fireEvent.click(screen.getByText('base64Converter:decode')); + const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'data:image/png;base64,iVBORw0KGgo=' }, }); - fireEvent.click(screen.getByText('switchToImageMode')); + fireEvent.click(screen.getByText('base64Converter:switchToImageMode')); expect(onSwitch).toHaveBeenCalledTimes(1); }); it('解码模式下对二进制数据应该显示更清晰的错误', async () => { render(); - fireEvent.click(screen.getByText('decode')); + fireEvent.click(screen.getByText('base64Converter:decode')); - const input = screen.getByPlaceholderText('base64InputPlaceholder'); + const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder'); fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } }); act(() => { @@ -177,7 +177,7 @@ describe('TextMode', () => { }); await waitFor(() => { - expect(screen.getByText('binaryDataDetected')).toBeInTheDocument(); + expect(screen.getByText('base64Converter:binaryDataDetected')).toBeInTheDocument(); }); }); }); diff --git a/pages/Base64Converter/__tests__/index.test.tsx b/pages/Base64Converter/__tests__/index.test.tsx index 41395c2..2a79764 100644 --- a/pages/Base64Converter/__tests__/index.test.tsx +++ b/pages/Base64Converter/__tests__/index.test.tsx @@ -73,11 +73,4 @@ describe('Base64ConverterPage', () => { }); expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument(); }); - - it('应该渲染页面标题', async () => { - render(); - await waitForStorageInit(); - expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument(); - expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument(); - }); }); diff --git a/pages/Base64Converter/index.tsx b/pages/Base64Converter/index.tsx index 485dd7d..5b53d44 100644 --- a/pages/Base64Converter/index.tsx +++ b/pages/Base64Converter/index.tsx @@ -1,7 +1,4 @@ -import { Image as ImageIcon, Type, Upload } from 'lucide-react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; -import PageHeader from '@/components/PageHeader'; -import { base64ConverterPageStyles } from '@/config/pageTheme'; import { useStorageState } from '@/utils/useStorageState'; import type { Base64ConverterPageMode } from '@/types/storage'; import TextMode from './TextMode'; @@ -22,22 +19,8 @@ export default function Index() { isValidPageMode, ); - const modeIcon: Record = { - text: , - file: , - image: , - }; - return (
- - (null); const [info, setInfo] = useState(null); diff --git a/pages/HtmlToMarkdown/index.tsx b/pages/HtmlToMarkdown/index.tsx index a2d2001..aab869b 100644 --- a/pages/HtmlToMarkdown/index.tsx +++ b/pages/HtmlToMarkdown/index.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo, useState } from 'react'; -import { Code, Download, Trash2 } from 'lucide-react'; +import { Download, Trash2 } from 'lucide-react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; -import PageHeader from '@/components/PageHeader'; import CopyButton from '@/components/CopyButton'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button @@ -51,12 +50,6 @@ export default function HtmlToMarkdownPage() { - 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。 */
- } - /> -
{/* 工具栏集成区 */}
diff --git a/pages/JsonTools/index.tsx b/pages/JsonTools/index.tsx index c8ba24a..e808363 100644 --- a/pages/JsonTools/index.tsx +++ b/pages/JsonTools/index.tsx @@ -1,7 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { ArrowRightLeft, Braces, GitCompareArrows, Minimize2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; -import PageHeader from '@/components/PageHeader'; import JsonDiffInput from './JsonDiffInput'; import DiffResult from './DiffResult'; import DiffNavigator from './DiffNavigator'; @@ -99,22 +97,6 @@ export default function Index() { const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; - const modeTitles: Record = { - diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' }, - format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' }, - yaml: { title: 'jsonFormat:yamlTitle', subtitle: 'jsonFormat:yamlSubtitle' }, - toml: { title: 'jsonFormat:tomlTitle', subtitle: 'jsonFormat:tomlSubtitle' }, - minify: { title: 'jsonFormat:minifyTitle', subtitle: 'jsonFormat:minifySubtitle' }, - }; - - const modeIcon: Record = { - diff: , - format: , - yaml: , - toml: , - minify: , - }; - const yamlConvert: ConvertFunction = useCallback((text: string) => { const r = jsonToYaml(text); return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; @@ -132,14 +114,6 @@ export default function Index() { return (
- - setPageMode(v)} diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index b79ca9f..189c0c7 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -1,6 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Key } from 'lucide-react'; -import PageHeader from '@/components/PageHeader'; import { parseJwt, stringifyJson } from '@/utils/jwt'; import CopyButton from '@/components/CopyButton'; import TextInputArea from '@/components/TextInputArea'; @@ -73,12 +71,6 @@ export default function Index() { return (
- } // 💡 规范锁死 Icon 宽高,抹杀闪烁 - /> -
{/* 输入终端 */} - } - className="pb-1" - /> -
{/* 工具集成控制中枢 */}
diff --git a/pages/QrCode/__tests__/index.test.tsx b/pages/QrCode/__tests__/index.test.tsx index 465b762..ff0145f 100644 --- a/pages/QrCode/__tests__/index.test.tsx +++ b/pages/QrCode/__tests__/index.test.tsx @@ -68,12 +68,6 @@ describe('QrCodePage', () => { expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument(); }); - it('应该渲染页面标题', () => { - render(); - expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument(); - expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument(); - }); - it('切换到解析模式应该渲染 ImageUploader', () => { render(); fireEvent.click(screen.getByText('qrCode:qrToUrl')); diff --git a/pages/QrCode/index.tsx b/pages/QrCode/index.tsx index d9797e5..cd2713b 100644 --- a/pages/QrCode/index.tsx +++ b/pages/QrCode/index.tsx @@ -1,7 +1,5 @@ -import { QrCode as QrCodeIcon } from 'lucide-react'; // 💡 别名规整,防止与页面组件发生重名误判 -import PageHeader from '@/components/PageHeader'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; -import { qrCodePageStyles } from '@/config/pageTheme'; + import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { QrCodeContext } from './contexts/QrCodeContext'; import { useQrCode } from './hooks/useQrCode'; @@ -26,15 +24,6 @@ export default function Index() { - 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排! */}
- {/* 标题控制栏:追加微调 py-0.5,防范文字边缘截断 */} - } // 💡 规范对齐:强制锁死 Icon 宽高,抹杀闪烁 - iconColor={qrCodePageStyles.primaryColor} - className="pb-1" - /> - {/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
({ + useRightClickRestorer: () => ({ + domain: 'example.com', + isLoading: false, + isUnlocked: false, + isUnsupported: false, + unlock: mockUnlock, + }), +})); + +describe('RightClickRestorerPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render current domain', () => { + render(); + expect(screen.getByText(/example\.com/)).toBeInTheDocument(); + }); + + it('should render locked status', () => { + render(); + expect(screen.getByText(/statusLocked/)).toBeInTheDocument(); + }); + + it('should call unlock when button clicked', () => { + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(mockUnlock).toHaveBeenCalled(); + }); +}); diff --git a/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts b/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts new file mode 100644 index 0000000..929051e --- /dev/null +++ b/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts @@ -0,0 +1,92 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useRightClickRestorer } from '../useRightClickRestorer'; +import { sendMessageToContent } from '@/utils/messages'; + +const mockTabsQuery = vi.fn(); + +vi.mock('@/utils/messages', () => ({ + MessageAction: { + RESTORE_RIGHT_CLICK: 'restoreRightClick', + QUERY_RIGHT_CLICK_STATUS: 'queryRightClickStatus', + }, + sendMessageToContent: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]); + chrome.tabs.query = mockTabsQuery; + vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false }); +}); + +describe('useRightClickRestorer', () => { + it('should load domain and query status', async () => { + const { result } = renderHook(() => useRightClickRestorer()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.domain).toBe('example.com'); + expect(result.current.isUnlocked).toBe(false); + expect(result.current.isUnsupported).toBe(false); + expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus'); + }); + + it('should mark internal pages as unsupported', async () => { + mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]); + chrome.tabs.query = mockTabsQuery; + + const { result } = renderHook(() => useRightClickRestorer()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.isUnsupported).toBe(true); + expect(result.current.isUnlocked).toBe(false); + expect(sendMessageToContent).not.toHaveBeenCalled(); + }); + + it('should unlock right click', async () => { + vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false }); + vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true }); + + const { result } = renderHook(() => useRightClickRestorer()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.unlock(); + }); + + expect(result.current.isUnlocked).toBe(true); + expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick'); + }); + + it('should not unlock unsupported pages', async () => { + mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]); + chrome.tabs.query = mockTabsQuery; + + const { result } = renderHook(() => useRightClickRestorer()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.unlock(); + }); + + expect(result.current.isUnlocked).toBe(false); + expect(sendMessageToContent).not.toHaveBeenCalled(); + }); + + it('should handle sendMessage failure gracefully', async () => { + vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed')); + + const { result } = renderHook(() => useRightClickRestorer()); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.unlock(); + }); + + expect(result.current.isUnlocked).toBe(false); + }); +}); diff --git a/pages/RightClickRestorer/index.tsx b/pages/RightClickRestorer/index.tsx new file mode 100644 index 0000000..31194c2 --- /dev/null +++ b/pages/RightClickRestorer/index.tsx @@ -0,0 +1,85 @@ +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react'; +import { useRightClickRestorer } from './useRightClickRestorer'; +import { useLazyTranslation } from '@/utils/useLazyTranslation'; + +export default function RightClickRestorerPage() { + const { t } = useLazyTranslation('rightClickRestorer'); + const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer(); + + if (isLoading) { + return ( +
+ + {t('rightClickRestorer:loading')} + +
+ ); + } + + return ( +
+ {/* Current Domain */} +
+
+ +
+ + {domain || '—'} + + {isUnsupported ? ( + + + {t('rightClickRestorer:unsupported')} + + ) : isUnlocked ? ( + + + {t('rightClickRestorer:statusUnlocked')} + + ) : ( + + + {t('rightClickRestorer:statusLocked')} + + )} +
+
+
+ + {/* Unlock Action */} +
+
+ {isUnsupported ? ( + <> +

+ {t('rightClickRestorer:unsupportedDesc')} +

+ + + ) : ( + <> +

{t('rightClickRestorer:unlockDesc')}

+ + + )} +
+
+
+ ); +} diff --git a/pages/RightClickRestorer/useRightClickRestorer.ts b/pages/RightClickRestorer/useRightClickRestorer.ts new file mode 100644 index 0000000..f7abeb4 --- /dev/null +++ b/pages/RightClickRestorer/useRightClickRestorer.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useState } from 'react'; +import { MessageAction, sendMessageToContent } from '@/utils/messages'; + +const UNSUPPORTED_PROTOCOLS = new Set([ + 'chrome:', + 'chrome-extension:', + 'about:', + 'edge:', + 'brave:', +]); + +function isUnsupportedPage(url: string | undefined): boolean { + if (!url) return true; + try { + const protocol = new URL(url).protocol; + return UNSUPPORTED_PROTOCOLS.has(protocol); + } catch { + return true; + } +} + +export interface UseRightClickRestorerReturn { + domain: string; + isLoading: boolean; + isUnlocked: boolean; + isUnsupported: boolean; + unlock: () => Promise; +} + +export function useRightClickRestorer(): UseRightClickRestorerReturn { + const [domain, setDomain] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isUnlocked, setIsUnlocked] = useState(false); + const [isUnsupported, setIsUnsupported] = useState(false); + + useEffect(() => { + const load = async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const url = tab?.url; + + if (url) { + try { + setDomain(new URL(url).hostname); + } catch { + setDomain(''); + } + } + + if (isUnsupportedPage(url)) { + setIsUnsupported(true); + setIsLoading(false); + return; + } + + const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS); + if (response?.success) { + setIsUnlocked(response.restored); + } + } catch (err) { + console.error('[RightClickRestorer] Failed to load state:', err); + } finally { + setIsLoading(false); + } + }; + + void load(); + }, []); + + const unlock = useCallback(async () => { + if (isUnsupported) return; + + try { + const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); + if (response?.success) { + setIsUnlocked(response.restored); + } + } catch (err) { + console.error('[RightClickRestorer] Failed to unlock:', err); + } + }, [isUnsupported]); + + return { + domain, + isLoading, + isUnlocked, + isUnsupported, + unlock, + }; +} diff --git a/pages/StorageCleaner/AutoRefreshToggle.tsx b/pages/StorageCleaner/AutoRefreshToggle.tsx index 301a35c..a062723 100644 --- a/pages/StorageCleaner/AutoRefreshToggle.tsx +++ b/pages/StorageCleaner/AutoRefreshToggle.tsx @@ -1,17 +1,44 @@ +/** + * 自动刷新开关组件 + * + * 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。 + * 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。 + * + * @example + * ```tsx + * setReloadAfterClean(checked)} + * /> + * ``` + */ + import React from 'react'; import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { cn } from '@/lib/utils'; -// 引入官方的 Switch 原子组件 import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -interface AutoRefreshToggleProps extends Omit, 'onChange'> { - autoRefresh: boolean; +export interface AutoRefreshToggleProps extends Omit< + React.HTMLAttributes, + 'onChange' +> { + /** 开关的当前状态(受控) */ + reloadAfterClean: boolean; + /** 状态变化时的回调函数 */ onChange: (checked: boolean) => void; } +/** + * 自动刷新开关 + * + * @param reloadAfterClean - 当前开关状态 + * @param onChange - 状态变化回调,接收新的布尔值 + * @param className - 额外的 CSS 类名,用于覆盖或扩展样式 + * @param props - 透传给外层 div 的其他 HTML 属性 + */ export default function AutoRefreshToggle({ - autoRefresh, + reloadAfterClean, onChange, className, ...props @@ -21,15 +48,12 @@ export default function AutoRefreshToggle({ return (
- {/* 3. 使用标准的 shadcn/ui Label 组件: - 绑定 htmlFor 建立安全的表单无障碍桥梁,使得用户点击文字也能触发开关联动 - */} + {/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
); } diff --git a/pages/StorageCleaner/index.tsx b/pages/StorageCleaner/index.tsx index db9a0df..b932a00 100644 --- a/pages/StorageCleaner/index.tsx +++ b/pages/StorageCleaner/index.tsx @@ -11,29 +11,26 @@ import { useLazyTranslation } from '@/utils/useLazyTranslation'; export default function Index() { const { t } = useLazyTranslation('storageCleaner'); - // 1. 完美对接全新重构、无需回调参数的纯净版状态 Hook const { error, isInitializing, options, sizes, - autoRefresh, + reloadAfterClean, loading, result, showConfirm, setShowConfirm, allSelected, someSelected, - handleAutoRefreshChange, + handleReloadAfterCleanChange, handleOptionChange, handleSelectAll, handleClean, } = useStorageCleaner(); - // 计算当前的按钮锁定状态:没有任何一项被勾选,或者正在清理中,则禁用大按钮 const isButtonDisabled = !(someSelected || allSelected) || loading; - // 2. 初始化骨架屏:全面升级为符合 shadcn 规范的无损微动效 Spinner if (isInitializing) { return (
@@ -45,18 +42,12 @@ export default function Index() { ); } - // 拦截非法域名或受限域名的错误提示页(ErrorDisplay 内部已在上一轮做好 p-4 居中) if (error) { return ; } return ( - /* 3. 终极版页面根容器: - - 彻底灌入 p-4,全局对齐线向内收缩 16px,终结贴边惨剧。 - - 用 space-y-3.5 替代块级外边距(mb-3 等),让所有卡片之间的垂直间距处于绝对一致的黄金比例。 - */
- {/* 存储网格核心中控面板 (内部已满血复活半选状态) */} - {/* 自动刷新开关 */} - + - {/* 4. 主行动按钮超进化: - - 彻底剥离 bg-amber-500,全面回归系统标准的 variant="destructive"。 - - 享受高风险操作该有的危险红警示,完美契合上一轮重构的二次确认弹窗基调。 - - 高级动态加载:当处于 cleaning 状态时,文字自动流转,且左侧自动淡入等宽 Loader2 图标。 - */}