From ff964e36089d349a7ababd6728a327cd38016417 Mon Sep 17 00:00:00 2001 From: LingandRX <56020800+LingandRX@users.noreply.github.com> Date: Wed, 6 May 2026 23:11:27 +0800 Subject: [PATCH] Develop (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 添加组件文档注释和类型导入 refactor: 统一使用 SnackbarOptions 类型 style: 优化导入语句顺序和格式 * refactor: 简化假数据生成器中的faker导入和使用 Co-authored-by: Copilot * feat(form-recognizer): 增强表单识别功能并优化UI交互 - 新增字段类型偏好设置功能,支持按域名保存字段类型 - 重构FieldList组件,改进字段选择和类型修改体验 - 添加字段定位闪烁功能,便于在页面上快速找到对应字段 - 优化表单填充逻辑,支持单个字段覆盖默认填充模式 - 移除独立的侧边栏页面,统一使用主页面组件 - 改进useStorageState钩子,增加加载状态管理和防抖处理 * refactor: 移除未使用的组件文件 * refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现 重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性 * style(组件): 调整自动刷新开关和存储选项网格的样式 优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式 * style(ui): 调整时间戳页面和结果视图的样式 - 为时区选择器添加圆角 - 优化结果视图的布局和对齐方式 - 调整结果项的内边距和文本样式 * ci(workflow): 移除Firefox测试以简化CI流程 仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗 * refactor(qrcode): 重构二维码解析功能并提取为独立模块 * style(页面样式): 统一页面头部图标颜色并优化表单样式 更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果 * feat(formMapping): 添加表单映射功能 - 创建 FormMappingPage 页面组件 - 实现表单扫描器 scanner.ts - 实现高亮器 highlighter.ts - 更新路由配置 routes.ts - 更新内容脚本 content.ts - 更新类型定义 storage.d.ts - 添加 formMappingPageStyles 样式配置 * feat(表单映射): 添加配置导出功能及状态提示 添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。 * feat(表单填充): 新增智能表单填充功能 添加智能表单填充功能,包括: 1. 新增表单填充页面和路由配置 2. 实现模糊匹配引擎和智能注入引擎 3. 添加Mock数据生成器和视觉反馈渲染器 4. 扩展消息接口支持填充操作 * ci: 精简触发CI的工作流分支 移除对develop分支及其变体的触发,仅保留main分支的触发 * test: 更新路由测试以匹配新增的路由数量 * test: 将测试文件中的描述和断言翻译为中文 * test(PageHeader): 添加组件测试用例验证渲染逻辑和样式 * refactor(theme): 重构主题样式并优化仪表盘卡片组件 将页面样式配置集中管理,移除各页面中硬编码的背景色 新增 DashboardCard 组件封装通用卡片逻辑 添加 dashboardCards 配置文件统一管理卡片数据 * refactor(Button): 统一按钮样式并移除重复样式定义 将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义 更新组件文档说明,提供更清晰的用法示例 * docs: 更新 README 文件中的目录结构说明 添加新组件和配置文件的说明,保持文档与代码同步 * refactor: 优化代码类型声明和UI布局 修复类型声明从any改为never以提高类型安全性 调整TimestampPage页面布局间距 移除DashboardPage中不必要的实时时钟状态 更新ESLint配置以使用推荐配置 添加选项页面打开的错误处理 * refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑 将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现 添加 ProtocolMap 类型定义确保消息类型安全 更新相关页面和后台脚本使用新的消息通信方式 * fix: 修复依赖项缺失导致的潜在问题 修复 useStorageState 中缺少 defaultValue 依赖的问题 重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能 * refactor: 重构表单识别和消息处理逻辑 将表单映射UI逻辑提取到独立文件 将消息处理器提取到独立文件 将表单识别页面逻辑提取到自定义hook 优化代码结构和可维护性 * feat(侧边栏): 添加侧边栏状态变化通知功能 - 在消息类型中新增侧边栏状态变化枚举和字段 - 侧边栏打开和关闭时发送状态通知 - 替换轮询检查方式为消息监听机制 * refactor: 优化高亮组件渲染逻辑并添加防抖处理 重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化 在 useStorageCleaner 中添加防抖处理以避免频繁加载 * refactor: 统一组件导出方式为默认导出 * refactor: 重构样式系统并迁移至 MUI 主题 删除冗余的 CSS 文件,统一使用 MUI 主题管理样式 新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook 优化各入口点的主题集成和布局处理 * docs: 更新项目文档以反映新增功能和技术栈细节 更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息 * feat: 添加错误边界组件以捕获子组件错误 在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。 * refactor(ui): 使用全局 snackbar 替换本地通知组件 重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知 标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性 * refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件 将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括: 1. 创建新的 SnackbarProvider 组件作为全局消息提示容器 2. 提供 useSnackbar hook 供子组件调用 3. 移除各页面中独立的 GlobalSnackbar 实例 4. 在 App 根组件中统一集成 SnackbarProvider 5. 优化消息提示样式和交互行为 * refactor(Snackbar): 重构消息提示组件并优化样式 重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用 优化消息提示样式,调整阴影效果和最小尺寸 统一各页面使用 Snackbar 的方式,移除冗余配置 * refactor: 重构路由和功能配置系统 - 将路由配置和仪表盘卡片配置合并为统一的 features 配置 - 重构 RouterProvider 增加数据校验和本地快照功能 - 优化表单字段识别和填充逻辑,提取公共方法 - 更新相关组件和文档以适配新的配置系统 - 删除废弃的 dashboardCards 和 routes 配置文件 * refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件 重构Snackbar相关代码,将SnackbarProvider合并到GlobalSnackbar组件中,简化代码结构 更新相关组件引用路径,从SnackbarProvider改为GlobalSnackbar 修复StorageCleanerPage禁用状态逻辑问题 * feat(表单映射): 增强高亮器功能并优化元素定位 - 新增高亮器闪烁功能,支持自定义持续时间 - 改进元素定位算法,支持 Shadow DOM 穿透 - 优化拾取模式,支持连续拾取和悬停预览 - 增强错误处理和信息反馈 - 添加测试用例确保核心功能稳定性 * refactor: 移除表单映射相关功能 删除表单映射相关的UI、逻辑和测试代码,包括表单识别、智能填充和高亮功能 简化消息处理逻辑和配置文件,保留核心功能 * feat: 实现页面组件懒加载并优化加载状态显示 refactor: 移除未使用的数据生成和验证工具类 chore: 更新应用图标文件 * refactor(qrcode): 替换二维码生成和解析库以减小体积 使用 qr-scanner 替代 jsqr 进行二维码解析,使用 qrious 替代 qrcode 进行二维码生成 新的库体积更小且性能更好,同时移除了不再使用的 @faker-js/faker 依赖 * feat: 添加在标签页打开功能并优化错误处理 修复测试中的加载状态检查 将控制台日志改为错误捕获 添加全局消息提示组件 * feat: 支持独立标签页模式并重构路由配置 重构路由系统以支持独立标签页模式,替换原有的独立窗口模式 - 新增标签页路由、可见页面列表和页面顺序的存储键 - 修改 RouterProvider 以支持不同窗口类型的独立配置 - 更新选项页面以支持为不同窗口类型单独配置 - 添加相关单元测试 * feat(ui): 优化响应式布局和样式设计 重构选项页面布局,增强移动端适配性 更新主题配置和组件样式,提升用户体验 * refactor: 移除URL工具相关代码 移除不再需要的URL工具功能,包括组件、页面、样式和类型定义 * refactor: 简化页面标题和存储选项网格的响应式布局 * refactor: 优化样式和错误处理 - 简化 chrome.runtime.openOptionsPage 的错误处理 - 拆分 html, body, #root 的样式定义,使用 minHeight 替代 height * refactor: 重构页面组件结构 - 将页面组件从 entrypoints/popup/pages 移动到 pages 目录 - 更新文档中的路径引用 * style(DashboardPage): 优化仪表盘布局为响应式网格 refactor(ToolCard): 重构工具卡片组件并添加详细注释 * feat: 新增文本统计功能 添加文本统计工具页面及相关功能,包括: - 文本统计工具页面组件 - 文本统计计算工具函数 - 相关类型定义和样式配置 - 单元测试覆盖核心功能 * feat(jwt): 添加 JWT 解析工具功能 新增 JWT 解析工具页面,包含以下内容: - 添加 JWT 类型定义和页面样式配置 - 实现 JWT 解析核心功能及测试用例 - 创建 JWT 解析页面组件 - 在功能配置中注册 JWT 工具 * docs: 更新 README 以更清晰地描述项目功能和结构 - 重构功能描述部分,使用更简洁的列表形式展示 - 更新技术栈和项目结构说明 - 优化开发流程部分的描述 - 移除冗余的权限和依赖说明 * docs: 更新AGENTS.md文档以反映项目最新状态 - 重写项目概述和核心命令部分,使其更简洁准确 - 重构目录结构描述,采用更清晰的树状格式 - 移除过时的功能模块描述,聚焦当前核心功能 - 更新AI代理开发准则和权限管理说明 - 优化文档整体结构和可读性 * test: 更新特征计数测试以匹配新增 textStatistics 和 jwt 工具 * style: 统一导入语句顺序 * refactor(pages): 重构工具页面组件结构并优化功能实现 - 将各工具页面组件迁移至独立目录并重构为模块化结构 - 新增存储清理工具相关组件及状态管理逻辑 - 实现时间戳转换工具的自定义hook和UI组件 - 优化JWT解析工具的代码结构和样式 - 更新文本统计工具的性能计算逻辑 - 调整配置文件中的页面引用路径 - 删除无用文件和过时文档 * feat(顶部栏): 添加搜索功能和历史记录 实现顶部栏搜索框功能,支持模糊搜索工具并显示搜索结果列表 添加搜索历史记录功能,自动保存最近搜索并显示 支持键盘导航和选择搜索结果 * feat: 添加多语言支持(i18n) 实现应用的多语言功能,支持中英文切换 添加i18next和相关依赖 创建语言资源文件 更新组件以使用翻译功能 重构文本统计工具页面 完善测试和构建配置 * feat(顶部栏): 添加语言切换功能并优化国际化支持 feat(i18n): 增加语言归一化和同步功能 fix(剪贴板): 使用国际化文本替换剪贴板消息 test(顶部栏): 更新测试以匹配国际化标题 * refactor: 重构组件目录结构,将组件移动到对应功能页面目录 将多个组件从通用组件目录移动到各自功能页面目录下,包括 ToolCard、StorageCleanerConfirm、UrlToQrCodeSection 和 QrCodeToUrlSection。同时新增 Dashboard 页面相关组件 DashboardCard 和 ToolCard,并优化了组件导入路径。 重构旨在提高代码组织性,使组件与其所属功能页面更紧密关联,便于维护和理解代码结构。所有组件功能保持不变,仅调整文件位置和导入路径。 * refactor(Dashboard): 优化卡片组件结构并移除无用属性 重构 DashboardCard 组件接口,直接传递属性而非配置对象 移除 ToolCard 中无用的 hasAI 属性 使用 useMemo 优化可见性判断性能 统一使用 alpha 函数处理颜色透明度 * refactor(timestamp): 合并输入处理函数并优化样式结构 重构时间戳转换器的输入处理逻辑,将 setTsInput 和 setDtInput 合并为统一的 setInput 函数 将组件样式提取到 pageTheme 配置中统一管理 使用 ToggleButtonGroup 替换自定义模式切换器实现 移除不必要的 showMessage 属性传递,改用 useSnackbar 钩子 * refactor(dashboard): 重构仪表盘卡片组件并优化布局 - 删除冗余的 DashboardCard 组件,直接使用 ToolCard - 将网格布局样式移至主题配置 - 使用 CardActionArea 增强可访问性 - 简化点击事件处理逻辑 - 更新相关测试用例 * refactor(storageCleaner): 重构样式代码并优化国际化处理 将分散的样式代码集中到pageTheme.ts中统一管理 优化组件中的国际化文本引用 修复useStorageCleaner中的状态同步问题 更新测试用例以匹配国际化文本 * refactor(StorageCleaner): 优化条件判断和清理未使用的代码 简化禁用按钮的条件判断逻辑 移除未使用的 resultTimeoutRef 及相关清理代码 * refactor(QrCode): 重构二维码页面样式和组件结构 将内联样式提取到主题配置文件中 移除未使用的全局消息提示依赖 优化组件结构和代码复用 删除未使用的QrCodeUploader组件 * refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色 移除 componentDidCatch 中的冗余 setState 调用 将错误背景色从 'error.shortest' 改为 rgba 格式 * feat(TopBar): 优化顶部导航栏功能并添加样式配置 - 新增 openExtensionPage 工具函数用于打开扩展页面 - 将顶部导航栏样式配置提取到 pageTheme 中统一管理 - 改进搜索历史功能,增加错误处理和配置化限制 - 优化键盘导航和搜索结果的交互逻辑 --------- Co-authored-by: Copilot Co-authored-by: yll --- AGENTS.md | 10 +- GEMINI.md | 83 --- components/DashboardCard.tsx | 46 -- components/ErrorBoundary.tsx | 13 +- components/GlobalSnackbar.tsx | 56 +- components/PageHeader.tsx | 2 +- components/QrCodeUploader.tsx | 381 ------------ components/RouterContainer.tsx | 2 +- components/StorageCleanerConfirm.tsx | 169 ----- components/ToolCard.tsx | 154 ----- components/TopBar.tsx | 298 ++++++++- components/UrlToQrCodeSection.tsx | 224 ------- components/__tests__/Button.test.tsx | 6 +- components/__tests__/GlobalSnackbar.test.tsx | 12 +- components/__tests__/PageHeader.test.tsx | 4 +- components/__tests__/RouterContainer.test.tsx | 4 +- .../__tests__/StorageCleanerConfirm.test.tsx | 40 +- components/__tests__/ToolCard.test.tsx | 63 +- components/__tests__/TopBar.test.tsx | 8 +- config/__tests__/features.test.ts | 16 +- config/features.tsx | 52 +- config/pageTheme.ts | 581 ++++++++++++++++++ entrypoints/options/App.tsx | 41 +- entrypoints/options/main.tsx | 1 + entrypoints/popup/main.tsx | 1 + entrypoints/sidepanel/main.tsx | 1 + i18n/index.ts | 138 +++++ i18n/locales/en/common.json | 23 + i18n/locales/en/features.json | 25 + i18n/locales/en/jwt.json | 10 + i18n/locales/en/qrCode.json | 31 + i18n/locales/en/storageCleaner.json | 33 + i18n/locales/en/textStatistics.json | 9 + i18n/locales/en/timestamp.json | 26 + i18n/locales/zh/common.json | 23 + i18n/locales/zh/features.json | 25 + i18n/locales/zh/jwt.json | 10 + i18n/locales/zh/qrCode.json | 31 + i18n/locales/zh/storageCleaner.json | 33 + i18n/locales/zh/textStatistics.json | 9 + i18n/locales/zh/timestamp.json | 26 + package-lock.json | 107 +++- package.json | 8 +- pages/Dashboard/ToolCard.tsx | 152 +++++ pages/Dashboard/index.tsx | 37 ++ pages/DashboardPage.tsx | 59 -- pages/{JwtPage.tsx => Jwt/index.tsx} | 99 +-- .../QrCode}/QrCodeToUrlSection.tsx | 178 +++--- pages/QrCode/UrlToQrCodeSection.tsx | 181 ++++++ pages/QrCode/index.tsx | 45 ++ pages/QrCodePage.tsx | 62 -- pages/StorageCleaner/AutoRefreshToggle.tsx | 26 + pages/StorageCleaner/CleaningResult.tsx | 27 + .../DomainHeader.tsx | 36 +- .../ErrorDisplay.tsx | 30 +- pages/StorageCleaner/OptionItem.tsx | 51 ++ .../StorageCleaner/StorageCleanerConfirm.tsx | 102 +++ pages/StorageCleaner/StorageOptionsGrid.tsx | 72 +++ .../index.tsx} | 33 +- .../{ => StorageCleaner}/useStorageCleaner.ts | 63 +- .../index.tsx} | 28 +- pages/Timestamp/LiveClock.tsx | 92 +++ pages/Timestamp/ResultView.tsx | 91 +++ pages/Timestamp/index.tsx | 116 ++++ .../useTimestampConverter.ts | 40 +- pages/TimestampPage.tsx | 182 ------ pages/components/AutoRefreshToggle.tsx | 51 -- pages/components/CleaningResult.tsx | 36 -- pages/components/LiveClock.tsx | 155 ----- pages/components/OptionItem.tsx | 107 ---- pages/components/ResultView.tsx | 145 ----- pages/components/StorageOptionsGrid.tsx | 136 ---- pages/hooks/useActiveTabDomain.ts | 29 - pages/hooks/useSidePanelState.ts | 60 -- providers/RouterProvider.tsx | 6 +- providers/__tests__/RouterProvider.test.tsx | 11 +- tsconfig.json | 5 +- types/storage.d.ts | 4 + utils/__tests__/jwt.test.ts | 4 +- utils/__tests__/storageCleaner.test.ts | 9 +- utils/__tests__/textStatistics.test.ts | 10 +- utils/chromeTabs.ts | 20 + utils/dayjs.ts | 2 - utils/storageCleaner.ts | 43 +- utils/textStatistics.ts | 14 +- utils/useStorageState.ts | 2 +- vite-env.d.ts | 1 - vitest.setup.ts | 37 +- 88 files changed, 2952 insertions(+), 2572 deletions(-) delete mode 100644 GEMINI.md delete mode 100644 components/DashboardCard.tsx delete mode 100644 components/QrCodeUploader.tsx delete mode 100644 components/StorageCleanerConfirm.tsx delete mode 100644 components/ToolCard.tsx delete mode 100644 components/UrlToQrCodeSection.tsx create mode 100644 i18n/index.ts create mode 100644 i18n/locales/en/common.json create mode 100644 i18n/locales/en/features.json create mode 100644 i18n/locales/en/jwt.json create mode 100644 i18n/locales/en/qrCode.json create mode 100644 i18n/locales/en/storageCleaner.json create mode 100644 i18n/locales/en/textStatistics.json create mode 100644 i18n/locales/en/timestamp.json create mode 100644 i18n/locales/zh/common.json create mode 100644 i18n/locales/zh/features.json create mode 100644 i18n/locales/zh/jwt.json create mode 100644 i18n/locales/zh/qrCode.json create mode 100644 i18n/locales/zh/storageCleaner.json create mode 100644 i18n/locales/zh/textStatistics.json create mode 100644 i18n/locales/zh/timestamp.json create mode 100644 pages/Dashboard/ToolCard.tsx create mode 100644 pages/Dashboard/index.tsx delete mode 100644 pages/DashboardPage.tsx rename pages/{JwtPage.tsx => Jwt/index.tsx} (67%) rename {components => pages/QrCode}/QrCodeToUrlSection.tsx (59%) create mode 100644 pages/QrCode/UrlToQrCodeSection.tsx create mode 100644 pages/QrCode/index.tsx delete mode 100644 pages/QrCodePage.tsx create mode 100644 pages/StorageCleaner/AutoRefreshToggle.tsx create mode 100644 pages/StorageCleaner/CleaningResult.tsx rename pages/{components => StorageCleaner}/DomainHeader.tsx (59%) rename pages/{components => StorageCleaner}/ErrorDisplay.tsx (58%) create mode 100644 pages/StorageCleaner/OptionItem.tsx create mode 100644 pages/StorageCleaner/StorageCleanerConfirm.tsx create mode 100644 pages/StorageCleaner/StorageOptionsGrid.tsx rename pages/{StorageCleanerPage.tsx => StorageCleaner/index.tsx} (64%) rename pages/{ => StorageCleaner}/useStorageCleaner.ts (86%) rename pages/{TextStatisticsPage.tsx => TextStatistics/index.tsx} (80%) create mode 100644 pages/Timestamp/LiveClock.tsx create mode 100644 pages/Timestamp/ResultView.tsx create mode 100644 pages/Timestamp/index.tsx rename pages/{hooks => Timestamp}/useTimestampConverter.ts (79%) delete mode 100644 pages/TimestampPage.tsx delete mode 100644 pages/components/AutoRefreshToggle.tsx delete mode 100644 pages/components/CleaningResult.tsx delete mode 100644 pages/components/LiveClock.tsx delete mode 100644 pages/components/OptionItem.tsx delete mode 100644 pages/components/ResultView.tsx delete mode 100644 pages/components/StorageOptionsGrid.tsx delete mode 100644 pages/hooks/useActiveTabDomain.ts delete mode 100644 pages/hooks/useSidePanelState.ts delete mode 100644 vite-env.d.ts diff --git a/AGENTS.md b/AGENTS.md index 742c1fe..e2769b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,11 +69,11 @@ npx vitest run path/to/your.test.ts │ └── sidepanel/ # 侧边栏界面主入口 ├── pages/ # 功能模块页面组件 │ ├── DashboardPage.tsx # 仪表盘/首页 -│ ├── JwtPage.tsx # JWT 解析工具 -│ ├── QrCodePage.tsx # 二维码工具 -│ ├── StorageCleanerPage.tsx # 存储清理工具 -│ ├── TextStatisticsPage.tsx # 文本统计工具 -│ └── TimestampPage.tsx # 时间戳转换工具 +│ ├── Jwt/index.tsx # JWT 解析工具 +│ ├── QrCode/index.tsx # 二维码工具 +│ ├── StorageCleaner/index.tsx # 存储清理工具 +│ ├── TextStatistics/index.tsx # 文本统计工具 +│ └── Timestamp/index.tsx # 时间戳转换工具 ├── providers/ # React Context Providers (Router, Theme 等) ├── utils/ # 业务逻辑与工具函数 │ ├── chromeStorage.ts # 类型安全的 Chrome Storage 封装 diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index f98f909..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,83 +0,0 @@ -# Testing Tools Browser Extension - Gemini Instructions - -This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project. - -## Project Overview - -**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools. - -### Tech Stack - -- **Framework:** WXT (Web Extension Toolkit) -- **Frontend:** React 19 + TypeScript -- **UI Library:** Material UI (MUI) @7.x -- **Date Handling:** dayjs (with UTC and timezone plugins) -- **Messaging:** @webext-core/messaging -- **Storage:** Type-safe Chrome Storage API wrapper -- **Testing:** Vitest + Testing Library (jsdom) - -### Architecture & Directory Structure - -- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content). - - `popup/`: Main UI shown when clicking the extension icon. - - `options/`: Extension settings page. - - `sidepanel/`: Browser side panel integration. - - `background.ts`: Background script for lifecycle management and background tasks. - - `content.ts`: Content script injected into web pages. -- `components/`: Reusable React components. -- `config/`: Application configuration, including routes and themes. -- `providers/`: React Context providers (e.g., `RouterProvider`). -- `utils/`: Utility functions and service abstractions. - - `chromeStorage.ts`: Type-safe storage utility. -- `types/`: Global TypeScript type definitions. -- `public/`: Static assets (icons, etc.). - -## Building and Running - -### Development - -- `npm run dev`: Start Chrome development mode with HMR. -- `npm run dev:firefox`: Start Firefox development mode. -- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`). - -### Production - -- `npm run build`: Build production version for Chrome. -- `npm run build:firefox`: Build production version for Firefox. -- `npm run zip`: Package the extension for Chrome Web Store. -- `npm run zip:firefox`: Package the extension for Firefox Add-ons. - -### Testing & Linting - -- `npm run test`: Run all tests once. -- `npm run test:watch`: Run tests in watch mode. -- `npm run test:coverage`: Run tests and generate coverage report. -- `npm run lint`: Run ESLint checks. - -## Development Conventions - -### Coding Style - -- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases. -- **Components:** Functional components with Hooks. Use MUI components for consistent UI. -- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`. -- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`. - -### Testing Practices - -- **Framework:** Vitest with `jsdom` environment. -- **Location:** Place tests in `__tests__` directories adjacent to the files being tested. -- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention. -- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions. - -### CI/CD - -- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build). -- **Releases:** Automatic release to GitHub on pushing a `v*` tag. - -## Key Considerations for AI Agents - -- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`. -- **Browser Compatibility:** Ensure features work in both Chrome and Firefox. -- **React 19:** Be aware of React 19 specific features and deprecations. -- **WXT Modules:** The project uses `@wxt-dev/module-react`. diff --git a/components/DashboardCard.tsx b/components/DashboardCard.tsx deleted file mode 100644 index 94bf0ee..0000000 --- a/components/DashboardCard.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import ToolCard from './ToolCard'; - -export interface DashboardCardConfig { - /** 卡片标题 */ - title: string; - /** 卡片描述文字 */ - description: string; - /** 主题颜色代码 */ - colorCode: string; - /** 图标组件 */ - icon: React.ReactNode; -} - -interface DashboardCardProps { - /** 卡片配置数据 */ - config: DashboardCardConfig; - /** 点击卡片时的回调函数 */ - onClick: () => void; - /** 卡片右侧的实时预览内容(如时间戳显示) */ - snapshot?: React.ReactNode; - /** 卡片背景色,默认使用主题色 */ - cardBackgroundColor?: string; -} - -/** - * DashboardCard - 仪表盘卡片组件 - * - * 基于 ToolCard 的封装,专门用于仪表盘页面 - * 使用 React.memo 避免不必要的重渲染 - */ -const DashboardCard = React.memo( - ({ config, onClick, snapshot, cardBackgroundColor }: DashboardCardProps) => ( - - ), -); - -export default DashboardCard; diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index fd1932b..c417b3f 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -1,5 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; -import { Box, Typography, Button, Paper, Container } from '@mui/material'; +import { Box, Button, Container, Paper, Typography } from '@mui/material'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -16,25 +16,24 @@ interface State { * 错误边界组件:捕获子组件树中的 JavaScript 错误 */ export class ErrorBoundary extends Component { - public state: State = { + state: State = { hasError: false, error: null, }; - public static getDerivedStateFromError(error: Error): State { + static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; } - public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + componentDidCatch(error: Error, errorInfo: ErrorInfo) { console.error('Uncaught error:', error, errorInfo); } private handleReset = () => { - this.setState({ hasError: false, error: null }); window.location.reload(); }; - public render() { + render() { if (this.state.hasError) { return ( @@ -46,7 +45,7 @@ export class ErrorBoundary extends Component { borderRadius: 4, border: '1px solid', borderColor: 'error.light', - bgcolor: 'error.shortest', + bgcolor: 'rgba(211, 47, 47, 0.04)', }} > diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index c1b3314..2c9f95e 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -197,24 +197,71 @@ export function GlobalSnackbar({ } /** - * useSnackbarState - 消息提示的状态管理 Hook + * useSnackbarState - 消息提示的 Hook 方式 * * 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。 + * 适合在组件内部使用,无需额外的状态管理代码。 * * @param {SnackbarOptions} [initialOptions] - 初始配置选项 * @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象 + * + * @description + * - 自动管理 Snackbar 的显示/隐藏状态 + * - 支持链式调用 showMessage + * - 合并初始选项和调用时选项 + * + * @example + * ```tsx + * function MyComponent() { + * const { snackbarProps, showMessage, closeMessage } = useSnackbarState({ + * severity: 'info', + * autoHideDuration: 3000, + * }); + * + * const handleSave = () => { + * // 业务逻辑... + * showMessage('保存成功!', { severity: 'success' }); + * }; + * + * return ( + * <> + * + * + * + * ); + * } + * ``` */ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult { + // Snackbar 显示状态 const [open, setOpen] = useState(false); + // 当前显示的消息内容 const [message, setMessage] = useState(''); + // 消息配置选项 const [options, setOptions] = useState(initialOptions || {}); + /** + * 显示消息 + * + * @param {string} newMessage - 要显示的消息文本 + * @param {SnackbarOptions} [newOptions={}] - 新的配置选项 + * + * @description + * - 合并初始选项和新的调用选项 + * - 新选项会覆盖初始选项 + */ const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => { setMessage(newMessage); setOptions({ ...initialOptions, ...newOptions }); setOpen(true); }; + /** + * 关闭消息 + * + * @description + * - 直接将 open 状态设置为 false + */ const closeMessage = () => { setOpen(false); }; @@ -224,6 +271,13 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS closeMessage(); }; + /** + * 传递给 GlobalSnackbar 组件的属性 + * + * @description + * - 组合当前状态和选项为完整的组件 props + * - onClose 使用 handleClose 包装后的版本 + */ const snackbarProps: GlobalSnackbarProps = { message, open, diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx index 9c4676c..f708a9c 100644 --- a/components/PageHeader.tsx +++ b/components/PageHeader.tsx @@ -1,4 +1,4 @@ -import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material'; +import { alpha, Box, Stack, SxProps, Theme, Typography } from '@mui/material'; import { ReactNode } from 'react'; /** diff --git a/components/QrCodeUploader.tsx b/components/QrCodeUploader.tsx deleted file mode 100644 index 90c2261..0000000 --- a/components/QrCodeUploader.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - Alert, - Box, - CircularProgress, - IconButton, - Paper, - Typography, - useMediaQuery, - useTheme, -} from '@mui/material'; -import ImageIcon from '@mui/icons-material/Image'; -import ClearIcon from '@mui/icons-material/Clear'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import ErrorIcon from '@mui/icons-material/Error'; -import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar'; -import CopyButton from './CopyButton'; -import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; - -interface QrCodeUploaderProps { - onQrCodeDetected?: (data: string) => void; - supportedFormats?: string[]; - maxFileSize?: number; // in bytes - timeout?: number; // in milliseconds - showPreview?: boolean; - showProgress?: boolean; - className?: string; -} - -const QrCodeUploader: React.FC = ({ - onQrCodeDetected, - supportedFormats = ['image/png', 'image/jpeg', 'image/webp'], - maxFileSize = 5 * 1024 * 1024, // 5MB - showPreview = true, - showProgress = true, - className, -}) => { - const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 }); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - - const [file, setFile] = useState(null); - const [preview, setPreview] = useState(null); - const [uploading, setUploading] = useState(false); - const [progress, setProgress] = useState(0); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - const [dragging, setDragging] = useState(false); - - const fileInputRef = useRef(null); - const uploadAreaRef = useRef(null); - - // 清理预览 URL - useEffect(() => { - return () => { - if (preview) { - URL.revokeObjectURL(preview); - } - }; - }, [preview]); - - // 处理文件 - const processFile = useCallback( - async (file: File) => { - setUploading(true); - setProgress(0); - - try { - const progressInterval = setInterval(() => { - setProgress((prev) => { - if (prev >= 90) { - clearInterval(progressInterval); - return prev; - } - return prev + 10; - }); - }, 200); - - const result = await parseQrCodeFromFile(file); - - clearInterval(progressInterval); - setProgress(100); - - if (result.success && result.data) { - setResult(result.data); - showMessage('二维码解析成功', { severity: 'success' }); - if (onQrCodeDetected) { - onQrCodeDetected(result.data); - } - } else { - setError(result.error || '未检测到二维码'); - showMessage(result.error || '未检测到二维码', { severity: 'error' }); - } - } catch (err) { - setError(err instanceof Error ? err.message : '解析失败'); - showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), { - severity: 'error', - }); - } finally { - setUploading(false); - setTimeout(() => setProgress(0), 500); - } - }, - [showMessage, onQrCodeDetected], - ); - - // 处理文件 - const handleFile = useCallback( - (selectedFile: File) => { - // 检查文件格式 - if (!supportedFormats.includes(selectedFile.type)) { - setError( - `不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`, - ); - showMessage('不支持的文件格式', { severity: 'error' }); - return; - } - - // 检查文件大小 - if (selectedFile.size > maxFileSize) { - const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1); - setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`); - showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' }); - return; - } - - // 重置状态 - setError(null); - setResult(null); - setFile(selectedFile); - - // 创建预览 - if (showPreview) { - const previewUrl = URL.createObjectURL(selectedFile); - setPreview(previewUrl); - } - - // 开始处理 - processFile(selectedFile).catch(console.error); - }, - [supportedFormats, maxFileSize, showPreview, showMessage, processFile], - ); - - // 处理文件选择 - const handleFileSelect = (e: React.ChangeEvent) => { - const selectedFile = e.target.files?.[0]; - if (selectedFile) { - handleFile(selectedFile); - } - }; - - // 处理拖拽事件 - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(true); - }; - - const handleDragLeave = () => { - setDragging(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragging(false); - const droppedFile = e.dataTransfer.files?.[0]; - if (droppedFile) { - handleFile(droppedFile); - } - }; - - // 监听粘贴事件 - useEffect(() => { - const handlePaste = (e: ClipboardEvent) => { - const items = e.clipboardData?.items; - if (!items) return; - - for (let i = 0; i < items.length; i++) { - if (items[i].type.startsWith('image/')) { - e.preventDefault(); - const pastedFile = items[i].getAsFile(); - if (pastedFile) { - handleFile(pastedFile); - } - break; - } - } - }; - - document.addEventListener('paste', handlePaste); - return () => document.removeEventListener('paste', handlePaste); - }, [handleFile]); - - // 清除文件 - const handleClear = () => { - setFile(null); - setPreview(null); - setResult(null); - setError(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }; - - return ( - - {/* 上传区域 */} - fileInputRef.current?.click()} - > - - - {!file && !uploading ? ( - - - - 点击、拖拽或粘贴上传二维码图片 - - - 支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式 - - - 最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB - - - ) : file && showPreview && preview ? ( - - QR Code Preview - { - e.stopPropagation(); - handleClear(); - }} - sx={{ - position: 'absolute', - top: -8, - right: -8, - bgcolor: 'rgba(244, 67, 54, 0.9)', - color: 'white', - '&:hover': { - bgcolor: 'rgba(211, 47, 47, 0.95)', - }, - }} - > - - - - {file.name} - - - ) : uploading && showProgress ? ( - - - - 处理中... - - {progress > 0 && ( - - - - - - {progress}% - - - )} - - ) : null} - - - {/* 结果展示 */} - {(result || error) && ( - - {result && ( - - - - - - 二维码内容 - - - - {result} - - - - - - - )} - - {error && ( - }> - {error} - - )} - - )} - - - - ); -}; - -export default QrCodeUploader; diff --git a/components/RouterContainer.tsx b/components/RouterContainer.tsx index 092ac91..3be590d 100644 --- a/components/RouterContainer.tsx +++ b/components/RouterContainer.tsx @@ -1,7 +1,7 @@ import { Box, CircularProgress } from '@mui/material'; import { FEATURES, getEntryPointType } from '@/config/features'; import { useRouter } from '@/providers/RouterProvider'; -import { useMemo, Suspense } from 'react'; +import { Suspense, useMemo } from 'react'; export default function RouterContainer() { const { currentPage, isLoaded } = useRouter(); diff --git a/components/StorageCleanerConfirm.tsx b/components/StorageCleanerConfirm.tsx deleted file mode 100644 index 240fb53..0000000 --- a/components/StorageCleanerConfirm.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Typography, - Box, - Chip, - alpha, -} from '@mui/material'; -import type { StorageCleanerOptions } from '@/types/storage'; -import Button from '@/components/Button'; -import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme'; - -export interface StorageCleanerConfirmProps { - open: boolean; - onClose: () => void; - onConfirm: () => void; - options: StorageCleanerOptions; -} - -const STORAGE_LABELS: Record = { - localStorage: 'LocalStorage', - sessionStorage: 'Session Storage', - indexedDB: 'IndexedDB', - cookies: 'Cookies', - cacheStorage: 'Cache Storage', - serviceWorkers: 'Service Workers', -}; - -export function StorageCleanerConfirm({ - open, - onClose, - onConfirm, - options, -}: StorageCleanerConfirmProps) { - const selectedOptions = Object.entries(options) - .filter(([_, value]) => value) - .map(([key, _]) => STORAGE_LABELS[key as keyof StorageCleanerOptions] || key); - - return ( - - - 确认清理数据? - - - - - 您将永久删除当前页面的以下选定存储项。 - - - - {selectedOptions.map((label) => ( - - ))} - - - - - - ⚠️ - {' '} - 此操作不可撤销 - - - - - - - - - - ); -} - -export default StorageCleanerConfirm; diff --git a/components/ToolCard.tsx b/components/ToolCard.tsx deleted file mode 100644 index 14ccd4a..0000000 --- a/components/ToolCard.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * ToolCard 组件 - 工具卡片 - * - * 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、 - * AI 标识和快照内容展示,具备悬停动画效果。 - */ -import { Box, Typography, Stack } from '@mui/material'; -import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI -import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; -import React from 'react'; - -/** - * ToolCard 组件属性接口 - */ -interface ToolCardProps { - /** 工具卡片标题 */ - title: string; - /** 工具卡片描述文本(可选) */ - description?: string; - /** 快照内容,用于在卡片底部展示额外信息(可选) */ - snapshot?: React.ReactNode; - /** 主题色代码,用于图标背景和悬停效果 */ - colorCode: string; - /** 工具图标元素 */ - icon: React.ReactNode; - /** 卡片点击事件处理函数 */ - onClick: () => void; - /** 是否显示 AI 标识(可选) */ - hasAI?: boolean; - /** 卡片背景色,默认为 'background.paper' */ - cardBackgroundColor?: string; -} - -/** - * ToolCard 组件 - * - * @param props - ToolCardProps 属性对象 - * @returns 工具卡片 JSX 元素 - */ -export default function ToolCard({ - title, - description, - snapshot, - colorCode, - icon, - onClick, - hasAI, - cardBackgroundColor = 'background.paper', -}: ToolCardProps) { - return ( - - - - - {icon} - - - - {title} - {hasAI && } - - {description && ( - - {description} - - )} - - - - - - {snapshot && ( - - {snapshot} - - )} - - ); -} diff --git a/components/TopBar.tsx b/components/TopBar.tsx index 3481043..b531525 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -1,18 +1,146 @@ -import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material'; +import { useState, useEffect, useRef, useMemo } from 'react'; +import { + Box, + IconButton, + Stack, + Tooltip, + Typography, + InputBase, + Paper, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ClickAwayListener, +} from '@mui/material'; import SettingsIcon from '@mui/icons-material/Settings'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import SearchIcon from '@mui/icons-material/Search'; +import HistoryIcon from '@mui/icons-material/History'; +import CloseIcon from '@mui/icons-material/Close'; +import LanguageIcon from '@mui/icons-material/Language'; import { useRouter } from '@/providers/RouterProvider'; +import { FEATURES, FeatureConfig } from '@/config/features'; +import { storageUtil } from '@/utils/chromeStorage'; +import { openExtensionPage } from '@/utils/chromeTabs'; +import { useTranslation } from 'react-i18next'; +import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n'; +import { topBarStyles } from '@/config/pageTheme'; export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { - const { currentPage, goBack } = useRouter(); + const { currentPage, goBack, navigateTo } = useRouter(); + const { t, i18n } = useTranslation(['common', 'features']); + const [searchQuery, setSearchQuery] = useState(''); + const [showResults, setShowResults] = useState(false); + const [searchHistory, setSearchHistory] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); - const handleOpenInTab = () => { - // 在新标签页中打开扩展页面 - chrome.tabs.create({ url: chrome.runtime.getURL('popup.html?mode=tab') }).catch(console.error); + // 加载搜索历史 + useEffect(() => { + storageUtil + .get('app/searchHistory', []) + .then((history) => { + setSearchHistory(history || []); + }) + .catch((error) => { + console.error('加载搜索历史失败:', error); + }); + }, []); + + // 模糊搜索逻辑 + const searchResults = useMemo(() => { + if (!searchQuery.trim()) return []; + const query = searchQuery.toLowerCase(); + return FEATURES.filter((f) => { + if (f.key === 'dashboard') return false; + const label = t(f.labelKey).toLowerCase(); + const desc = t(f.descriptionKey).toLowerCase(); + return label.includes(query) || desc.includes(query); + }); + }, [searchQuery, t]); + + const displayedHistory = useMemo(() => { + if (searchQuery.trim()) return []; + return searchHistory.slice(0, topBarStyles.SEARCH_HISTORY_DISPLAY); + }, [searchHistory, searchQuery]); + + const handleOpenInTab = async () => { + await openExtensionPage('popup.html', { mode: 'tab' }); window.close(); }; + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value); + setShowResults(true); + setSelectedIndex(-1); + }; + + const saveToHistory = async (query: string) => { + if (!query.trim()) return; + setSearchHistory((prev) => { + const newHistory = [query, ...prev.filter((h) => h !== query)].slice( + 0, + topBarStyles.SEARCH_HISTORY_LIMIT, + ); + storageUtil.set('app/searchHistory', newHistory).catch((error) => { + console.error('保存搜索历史失败:', error); + }); + return newHistory; + }); + }; + + const handleSelectFeature = (feature: FeatureConfig) => { + navigateTo(feature.key); + saveToHistory(t(feature.labelKey)); + setSearchQuery(''); + setShowResults(false); + }; + + const toggleLanguage = async () => { + const currentLng = normalizeLanguage(i18n.language); + const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng); + const nextIndex = (currentIndex + 1) % SUPPORTED_LANGUAGES.length; + const newLng = SUPPORTED_LANGUAGES[nextIndex]; + await i18n.changeLanguage(newLng); + await storageUtil.set('app/language', newLng); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); + } else if (e.key === 'Enter') { + if (selectedIndex >= 0) { + if (searchQuery.trim()) { + handleSelectFeature(searchResults[selectedIndex]); + } else { + const selectedQuery = displayedHistory[selectedIndex]; + setSearchQuery(selectedQuery); + setSelectedIndex(-1); + // 触发搜索:如果匹配到功能则跳转,否则保持搜索词展示结果 + const matchedFeature = FEATURES.find( + (f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery, + ); + if (matchedFeature) { + handleSelectFeature(matchedFeature); + } + } + } else if (searchQuery.trim() && searchResults.length > 0) { + handleSelectFeature(searchResults[0]); + } + } else if (e.key === 'Escape') { + setShowResults(false); + inputRef.current?.blur(); + } + }; + const isDashboard = currentPage === 'dashboard'; return ( @@ -21,12 +149,13 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) justifyContent="space-between" alignItems="center" sx={{ - px: { xs: 1.5, sm: 2 }, + px: { xs: 1, sm: 2 }, py: 1.5, borderBottom: '1px solid', borderColor: 'grey.100', bgcolor: 'background.paper', - zIndex: 1100, + zIndex: topBarStyles.Z_INDEX, + position: 'relative', }} > @@ -34,6 +163,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) void }) textTransform: 'uppercase', fontSize: '0.75rem', color: 'text.secondary', + ml: 1, + display: { xs: 'none', md: 'block' }, }} > - Testing Tools + {t('common:appName')} + + setShowResults(false)}> + + setShowResults(true)} + onKeyDown={handleKeyDown} + inputProps={{ 'aria-label': t('common:buttons.search') }} + startAdornment={} + endAdornment={ + searchQuery && ( + { + setSearchQuery(''); + setSelectedIndex(-1); + }} + aria-label={t('common:buttons.clearSearch')} + > + + + ) + } + sx={{ + width: '100%', + bgcolor: 'grey.50', + px: 1.5, + py: 0.5, + borderRadius: 2, + fontSize: '0.875rem', + border: '1px solid transparent', + transition: 'all 0.2s', + '&:hover': { bgcolor: 'grey.100' }, + '&.Mui-focused': { + bgcolor: 'background.paper', + borderColor: 'primary.main', + boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.1)', + }, + }} + /> + + {showResults && (searchQuery.trim() || displayedHistory.length > 0) && ( + + + {searchQuery.trim() ? ( + searchResults.length > 0 ? ( + searchResults.map((feature, index) => ( + handleSelectFeature(feature)} + role="option" + aria-selected={selectedIndex === index} + sx={{ py: 1 }} + > + {feature.icon} + + + )) + ) : ( + + + {t('common:buttons.noResults')} + + + ) + ) : ( + <> + + + {t('common:buttons.recentSearch')} + + + {displayedHistory.map((item, index) => ( + { + setSearchQuery(item); + setSelectedIndex(-1); + }} + role="option" + aria-selected={selectedIndex === index} + > + + + + + + ))} + + )} + + + )} + + + + - - + + + + + + + - - + + diff --git a/components/UrlToQrCodeSection.tsx b/components/UrlToQrCodeSection.tsx deleted file mode 100644 index 4081a87..0000000 --- a/components/UrlToQrCodeSection.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import React, { useState } from 'react'; -import { - Box, - Typography, - TextField, - Button, - Stack, - Accordion, - AccordionSummary, - AccordionDetails, - CircularProgress, -} from '@mui/material'; -import QrCodeIcon from '@mui/icons-material/QrCode'; -import DownloadIcon from '@mui/icons-material/Download'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import QRious from 'qrious'; -import { qrCodePageStyles } from '@/config/pageTheme'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; -import { copyImageToClipboard } from '@/utils/clipboard'; - -interface UrlToQrCodeSectionProps { - expanded: boolean; - onExpandedChange: (expanded: boolean) => void; - showMessage: (message: string, options?: SnackbarOptions) => void; -} - -const UrlToQrCodeSection = ({ - expanded, - onExpandedChange, - showMessage, -}: UrlToQrCodeSectionProps) => { - const [urlInput, setUrlInput] = useState(''); - const [urlError, setUrlError] = useState(''); - const [qrCodeDataUrl, setQrCodeDataUrl] = useState(''); - const [generating, setGenerating] = useState(false); - - const handleUrlInputChange = (e: React.ChangeEvent) => { - setUrlInput(e.target.value); - setUrlError(''); - }; - - const generateQrCode = async () => { - if (!urlInput) { - setUrlError('请输入 URL'); - return; - } - - try { - setGenerating(true); - setUrlError(''); - - let url = urlInput; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } - - // 使用 QRious 替代 qrcode 库,体积更小 - const qr = new QRious({ - value: url, - size: 250, - level: 'H', - foreground: qrCodePageStyles.black, - background: qrCodePageStyles.white, - }); - - setQrCodeDataUrl(qr.toDataURL()); - showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 }); - } catch (error) { - console.error('生成二维码失败:', error); - showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 }); - } finally { - setGenerating(false); - } - }; - - const downloadQrCode = () => { - if (!qrCodeDataUrl) return; - - const link = document.createElement('a'); - link.href = qrCodeDataUrl; - link.download = 'qrcode.png'; - link.click(); - showMessage('二维码下载成功', { severity: 'success' }); - }; - - const copyQrCode = async () => { - if (!qrCodeDataUrl) return; - - const response = await fetch(qrCodeDataUrl); - const blob = await response.blob(); - await copyImageToClipboard(blob) - .then(() => { - showMessage('二维码已复制到剪贴板', { severity: 'success' }); - }) - .catch(() => { - showMessage('复制二维码失败,请重试', { severity: 'error' }); - }); - }; - - return ( - onExpandedChange(isExpanded)} - sx={{ - borderRadius: 4, - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', - '&:before': { display: 'none' }, - }} - > - } sx={{ borderBottom: 'none' }}> - - - - URL 转二维码 - - - - - - - - - - - {qrCodeDataUrl ? ( - - QR Code - - - - - - ) : ( - - 二维码将显示在这里 - - )} - - - - - ); -}; - -export default UrlToQrCodeSection; diff --git a/components/__tests__/Button.test.tsx b/components/__tests__/Button.test.tsx index 01cfc85..56cf1b2 100644 --- a/components/__tests__/Button.test.tsx +++ b/components/__tests__/Button.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import Button from '../Button'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import Button from '@/components/Button'; describe('Button 组件', () => { beforeEach(() => { diff --git a/components/__tests__/GlobalSnackbar.test.tsx b/components/__tests__/GlobalSnackbar.test.tsx index 4176ae4..ee37059 100644 --- a/components/__tests__/GlobalSnackbar.test.tsx +++ b/components/__tests__/GlobalSnackbar.test.tsx @@ -1,13 +1,13 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, renderHook, screen } from '@testing-library/react'; import React from 'react'; import { GlobalSnackbar, - useSnackbarState, - useSnackbar, - SnackbarProvider, type GlobalSnackbarProps, -} from '../GlobalSnackbar'; + SnackbarProvider, + useSnackbar, + useSnackbarState, +} from '@/components/GlobalSnackbar'; describe('GlobalSnackbar 组件系统', () => { const mockOnClose = vi.fn(); diff --git a/components/__tests__/PageHeader.test.tsx b/components/__tests__/PageHeader.test.tsx index 8ddfb7a..a601f24 100644 --- a/components/__tests__/PageHeader.test.tsx +++ b/components/__tests__/PageHeader.test.tsx @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CloseIcon from '@mui/icons-material/Close'; import { render, screen } from '@testing-library/react'; -import PageHeader, { type PageHeaderProps } from '../PageHeader'; +import PageHeader, { type PageHeaderProps } from '@/components/PageHeader'; describe('PageHeader 组件系统', () => { beforeEach(() => { diff --git a/components/__tests__/RouterContainer.test.tsx b/components/__tests__/RouterContainer.test.tsx index 09f457e..3cdbf08 100644 --- a/components/__tests__/RouterContainer.test.tsx +++ b/components/__tests__/RouterContainer.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import RouterContainer from '../RouterContainer'; +import RouterContainer from '@/components/RouterContainer'; import { RouterProvider } from '@/providers/RouterProvider'; import { SnackbarProvider } from '@/components/GlobalSnackbar'; import type { PageType } from '@/types/storage'; diff --git a/components/__tests__/StorageCleanerConfirm.test.tsx b/components/__tests__/StorageCleanerConfirm.test.tsx index e9e4782..b307439 100644 --- a/components/__tests__/StorageCleanerConfirm.test.tsx +++ b/components/__tests__/StorageCleanerConfirm.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { StorageCleanerConfirm } from '../StorageCleanerConfirm'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm'; import type { StorageCleanerOptions } from '@/types/storage'; import React from 'react'; @@ -36,25 +36,27 @@ describe('StorageCleanerConfirm 组件', () => { describe('渲染测试', () => { it('open 为 true 时应渲染对话框', () => { renderComponent(); - expect(screen.getByText('确认清理数据?')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:confirmTitle')).toBeInTheDocument(); }); it('应显示警告信息', () => { renderComponent(); - expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument(); + expect(screen.getByText(/storageCleaner:irreversible/i)).toBeInTheDocument(); }); it('应将选中的选项显示为标签', () => { renderComponent(); - expect(screen.getByText('LocalStorage')).toBeInTheDocument(); - expect(screen.getByText('Session Storage')).toBeInTheDocument(); - expect(screen.getByText('Cookies')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument(); }); it('应显示取消和确认按钮', () => { renderComponent(); - expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /common:buttons.cancel/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /storageCleaner:confirmAction/i }), + ).toBeInTheDocument(); }); }); @@ -62,7 +64,7 @@ describe('StorageCleanerConfirm 组件', () => { it('点击取消时应调用 onClose', () => { renderComponent(); - fireEvent.click(screen.getByRole('button', { name: /取消/i })); + fireEvent.click(screen.getByRole('button', { name: /common:buttons.cancel/i })); expect(mockOnClose).toHaveBeenCalledTimes(1); expect(mockOnConfirm).not.toHaveBeenCalled(); }); @@ -70,7 +72,7 @@ describe('StorageCleanerConfirm 组件', () => { it('点击确认时应调用 onConfirm', () => { renderComponent(); - fireEvent.click(screen.getByRole('button', { name: /确认清理/i })); + fireEvent.click(screen.getByRole('button', { name: /storageCleaner:confirmAction/i })); expect(mockOnConfirm).toHaveBeenCalledTimes(1); expect(mockOnClose).not.toHaveBeenCalled(); }); @@ -89,10 +91,10 @@ describe('StorageCleanerConfirm 组件', () => { renderComponent({ options: partialOptions }); - expect(screen.getByText('LocalStorage')).toBeInTheDocument(); - expect(screen.getByText('IndexedDB')).toBeInTheDocument(); - expect(screen.queryByText('Session Storage')).not.toBeInTheDocument(); - expect(screen.queryByText('Cookies')).not.toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.indexedDB')).toBeInTheDocument(); + expect(screen.queryByText('storageCleaner:options.sessionStorage')).not.toBeInTheDocument(); + expect(screen.queryByText('storageCleaner:options.cookies')).not.toBeInTheDocument(); }); it('应处理空选项', () => { @@ -115,7 +117,7 @@ describe('StorageCleanerConfirm 组件', () => { describe('对话框行为测试', () => { it('open 为 false 时不应渲染', () => { renderComponent({ open: false }); - expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument(); + expect(screen.queryByText('storageCleaner:confirmTitle')).not.toBeInTheDocument(); }); it('应使用不同选项渲染', () => { @@ -130,8 +132,8 @@ describe('StorageCleanerConfirm 组件', () => { renderComponent({ options: customOptions }); - expect(screen.getByText('Session Storage')).toBeInTheDocument(); - expect(screen.getByText('Cookies')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument(); + expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument(); }); }); }); diff --git a/components/__tests__/ToolCard.test.tsx b/components/__tests__/ToolCard.test.tsx index 8420f05..23c194a 100644 --- a/components/__tests__/ToolCard.test.tsx +++ b/components/__tests__/ToolCard.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import ToolCard from '../ToolCard'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import ToolCard from '@/pages/Dashboard/ToolCard'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; describe('ToolCard 组件', () => { @@ -76,37 +76,20 @@ describe('ToolCard 组件', () => { expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); }); - }); - describe('AI 徽章测试', () => { - it('hasAI 为 true 时应渲染 AI 徽章', () => { + it('应使用 CardActionArea 渲染,支持键盘聚焦', () => { render( } onClick={() => {}} />, ); - const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon'); - expect(autoAwesomeIcon).toBeInTheDocument(); - }); - - it('hasAI 为 false 时不应渲染 AI 徽章', () => { - render( - } - onClick={() => {}} - />, - ); - - const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon'); - expect(autoAwesomeIcon).not.toBeInTheDocument(); + const button = screen.getByRole('button', { name: /可聚焦/ }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('tabIndex', '0'); }); }); @@ -122,10 +105,25 @@ describe('ToolCard 组件', () => { />, ); - const card = screen.getByText('可点击').closest('.MuiBox-root'); - if (card) { - fireEvent.click(card); - } + const button = screen.getByRole('button', { name: /可点击/ }); + fireEvent.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('按 Enter 键时应调用 onClick', () => { + const handleClick = vi.fn(); + render( + } + onClick={handleClick} + />, + ); + + const button = screen.getByRole('button', { name: /键盘可触发/ }); + fireEvent.click(button); expect(handleClick).toHaveBeenCalledTimes(1); }); @@ -134,17 +132,16 @@ describe('ToolCard 组件', () => { describe('样式测试', () => { it('应应用自定义颜色代码', () => { const customColor = '#ff5722'; - const { container } = render( + render( } + icon={} onClick={() => {}} />, ); - const iconContainer = container.querySelector('.MuiBox-root > div'); - expect(iconContainer).toBeInTheDocument(); + expect(screen.getByTestId('custom-color-icon')).toBeInTheDocument(); }); }); }); diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx index fa379bc..3a9a6ba 100644 --- a/components/__tests__/TopBar.test.tsx +++ b/components/__tests__/TopBar.test.tsx @@ -1,6 +1,6 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import TopBar from '../TopBar'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import TopBar from '@/components/TopBar'; import { RouterProvider } from '@/providers/RouterProvider'; import type { PageType } from '@/types/storage'; import React from 'react'; @@ -35,7 +35,7 @@ describe('TopBar 组件', () => { describe('渲染测试', () => { it('应使用默认标题渲染', () => { renderWithProvider(); - expect(screen.getByText('Testing Tools')).toBeInTheDocument(); + expect(screen.getByText('common:appName')).toBeInTheDocument(); }); it('不在 dashboard 时应渲染返回按钮', () => { diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts index 7d2562e..ceef5fc 100644 --- a/config/__tests__/features.test.ts +++ b/config/__tests__/features.test.ts @@ -5,7 +5,7 @@ import { getDefaultPageOrder, getDefaultVisibleFeatureKeys, getFeatureByKey, -} from '../features'; +} from '@/config/features'; describe('features', () => { describe('FEATURES', () => { @@ -16,13 +16,13 @@ describe('features', () => { it('should have all required properties for each feature', () => { FEATURES.forEach((feature) => { expect(feature).toHaveProperty('key'); - expect(feature).toHaveProperty('label'); - expect(feature).toHaveProperty('description'); + expect(feature).toHaveProperty('labelKey'); + expect(feature).toHaveProperty('descriptionKey'); expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('components'); expect(typeof feature.key).toBe('string'); - expect(typeof feature.label).toBe('string'); - expect(typeof feature.description).toBe('string'); + expect(typeof feature.labelKey).toBe('string'); + expect(typeof feature.descriptionKey).toBe('string'); expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.components).toBe('object'); expect(feature.components).toHaveProperty('popup'); @@ -50,14 +50,14 @@ describe('features', () => { const feature = getFeatureByKey('dashboard'); expect(feature).toBeDefined(); expect(feature?.key).toBe('dashboard'); - expect(feature?.label).toBe('Dashboard'); + expect(feature?.labelKey).toBe('features:dashboard.title'); }); it('should return timestamp feature', () => { const feature = getFeatureByKey('timestamp'); expect(feature).toBeDefined(); expect(feature?.key).toBe('timestamp'); - expect(feature?.label).toBe('时间戳'); + expect(feature?.labelKey).toBe('features:timestamp.title'); expect(feature?.themeColor).toBeDefined(); }); @@ -65,7 +65,7 @@ describe('features', () => { const feature = getFeatureByKey('storageCleaner'); expect(feature).toBeDefined(); expect(feature?.key).toBe('storageCleaner'); - expect(feature?.label).toBe('存储清理'); + expect(feature?.labelKey).toBe('features:storageCleaner.title'); }); it('should return undefined for invalid key', () => { diff --git a/config/features.tsx b/config/features.tsx index 0977994..82cec13 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, lazy } from 'react'; +import { type ComponentType, lazy, ReactNode } from 'react'; import type { PageType } from '@/types/storage'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import StorageIcon from '@mui/icons-material/Storage'; @@ -9,12 +9,12 @@ import VpnKeyIcon from '@mui/icons-material/VpnKey'; import { THEME_COLORS } from './pageTheme'; // 懒加载页面组件 -const DashboardPage = lazy(() => import('@/pages/DashboardPage')); -const TimestampPage = lazy(() => import('@/pages/TimestampPage')); -const StorageCleanerPage = lazy(() => import('@/pages/StorageCleanerPage')); -const QrCodePage = lazy(() => import('@/pages/QrCodePage')); -const TextStatisticsPage = lazy(() => import('@/pages/TextStatisticsPage')); -const JwtPage = lazy(() => import('@/pages/JwtPage')); +const DashboardPage = lazy(() => import('@/pages/Dashboard')); +const TimestampPage = lazy(() => import('@/pages/Timestamp')); +const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner')); +const QrCodePage = lazy(() => import('@/pages/QrCode')); +const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics')); +const JwtPage = lazy(() => import('@/pages/Jwt')); /** * 功能配置接口 @@ -24,10 +24,10 @@ const JwtPage = lazy(() => import('@/pages/JwtPage')); export interface FeatureConfig { /** 页面类型标识 */ key: PageType; - /** 功能名称(用于路由标签和卡片标题) */ - label: string; - /** 功能描述(用于仪表盘卡片) */ - description: string; + /** 功能名称翻译键 */ + labelKey: string; + /** 功能描述翻译键 */ + descriptionKey: string; /** 主题颜色(用于仪表盘卡片) */ themeColor?: string; /** 图标组件(用于仪表盘卡片) */ @@ -37,19 +37,19 @@ export interface FeatureConfig { /** 不同显示模式对应的组件 */ components: { /** 弹窗模式组件 */ - popup: React.ComponentType; + popup: ComponentType; /** 侧边栏模式组件 */ - sidepanel: React.ComponentType; + sidepanel: ComponentType; /** 标签页模式组件 */ - tab: React.ComponentType; + tab: ComponentType; }; } export const FEATURES: FeatureConfig[] = [ { key: 'dashboard', - label: 'Dashboard', - description: '', + labelKey: 'features:dashboard.title', + descriptionKey: '', defaultVisible: true, components: { popup: DashboardPage, @@ -59,8 +59,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'timestamp', - label: '时间戳', - description: 'Unix 毫秒数转换与格式化', + labelKey: 'features:timestamp.title', + descriptionKey: 'features:timestamp.description', themeColor: THEME_COLORS.primary, icon: , defaultVisible: true, @@ -72,8 +72,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'storageCleaner', - label: '存储清理', - description: '清理缓存、Cookies 及本地存储', + labelKey: 'features:storageCleaner.title', + descriptionKey: 'features:storageCleaner.description', themeColor: THEME_COLORS.warning, icon: , defaultVisible: true, @@ -85,8 +85,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'qrCode', - label: '二维码工具', - description: '生成当前选中的 URL 的二维码', + labelKey: 'features:qrCode.title', + descriptionKey: 'features:qrCode.description', themeColor: THEME_COLORS.success, icon: , defaultVisible: true, @@ -98,8 +98,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'textStatistics', - label: '文本统计', - description: '实时分析文本字符、单词及字节', + labelKey: 'features:textStatistics.title', + descriptionKey: 'features:textStatistics.description', themeColor: THEME_COLORS.purple, icon: , defaultVisible: true, @@ -111,8 +111,8 @@ export const FEATURES: FeatureConfig[] = [ }, { key: 'jwt', - label: 'JWT 解析', - description: 'JSON Web Token 解码与查看', + labelKey: 'features:jwt.title', + descriptionKey: 'features:jwt.description', themeColor: THEME_COLORS.indigo, icon: , defaultVisible: true, diff --git a/config/pageTheme.ts b/config/pageTheme.ts index aa86c54..edb7152 100644 --- a/config/pageTheme.ts +++ b/config/pageTheme.ts @@ -104,6 +104,11 @@ export const timestampPageStyles = { fontWeight: 600, }, }, + SELECT_MENU_PROPS: { + PaperProps: { + sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' }, + }, + }, cardBg: alpha(THEME_COLORS.primary, 0.04), cardBorder: alpha(THEME_COLORS.primary, 0.1), switcherBg: alpha(THEME_COLORS.primary, 0.08), @@ -111,6 +116,161 @@ export const timestampPageStyles = { mutedText: alpha(THEME_COLORS.primary, 0.4), resultBg: alpha(THEME_COLORS.primary, 0.05), buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`, + /** 模式切换器 (ToggleButtonGroup) 样式 */ + MODE_SWITCHER: { + width: '100%', + mb: 2.5, + borderRadius: 4, + bgcolor: 'grey.100', + border: '1px solid', + borderColor: 'grey.200', + p: 0.6, + '& .MuiToggleButtonGroup-grouped': { + flex: 1, + border: 'none', + borderRadius: 3.5, + py: 1, + fontWeight: 800, + fontSize: '0.75rem', + color: 'text.secondary', + transition: 'color 0.3s', + '&.Mui-selected': { + bgcolor: '#fff', + color: 'primary.main', + boxShadow: '0 4px 12px rgba(0,0,0,0.05)', + }, + }, + }, + /** 单位切换器样式 */ + UNIT_SWITCHER_CONTAINER: { + flex: 1, + display: 'flex', + bgcolor: 'grey.50', + p: 0.5, + borderRadius: 3.5, + border: '1px solid', + borderColor: 'grey.100', + }, + UNIT_SWITCHER_ITEM: (active: boolean) => ({ + flex: 1, + py: 0.8, + textAlign: 'center', + borderRadius: 3, + cursor: 'pointer', + fontSize: '0.75rem', + fontWeight: 800, + transition: 'all 0.2s', + bgcolor: active ? '#fff' : 'transparent', + color: active ? 'primary.main' : 'text.disabled', + boxShadow: active ? '0 2px 8px rgba(0,0,0,0.05)' : 'none', + }), + /** LiveClock 卡片样式 */ + LIVE_CLOCK_CARD: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: 1.5, + p: 1.8, + mb: 2.5, + bgcolor: alpha(THEME_COLORS.primary, 0.04), + borderRadius: 4, + border: '1px solid', + borderColor: alpha(THEME_COLORS.primary, 0.1), + }, + LIVE_CLOCK_LABEL: { + color: THEME_COLORS.primary, + fontWeight: 800, + fontSize: '0.6rem', + textTransform: 'uppercase', + letterSpacing: 1, + }, + LIVE_CLOCK_VALUE: { + fontWeight: 800, + color: THEME_COLORS.primary, + fontFamily: 'monospace', + fontSize: { xs: '1.1rem', sm: '1.2rem' }, + letterSpacing: '-0.5px', + lineHeight: 1.2, + }, + LIVE_CLOCK_UNIT_SWITCHER: { + display: 'flex', + p: 0.4, + bgcolor: alpha(THEME_COLORS.primary, 0.08), + borderRadius: 2.5, + border: '1px solid', + borderColor: alpha(THEME_COLORS.primary, 0.1), + }, + LIVE_CLOCK_UNIT_ITEM: (active: boolean) => ({ + px: { xs: 1, sm: 1.2 }, + py: 0.35, + borderRadius: 2, + cursor: 'pointer', + fontSize: '0.65rem', + fontWeight: 900, + transition: 'all 0.2s', + bgcolor: active ? '#fff' : 'transparent', + color: active ? 'primary.main' : alpha(THEME_COLORS.primary, 0.4), + boxShadow: active ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none', + }), + LIVE_CLOCK_ICON_BUTTON: { + color: THEME_COLORS.primary, + bgcolor: '#fff', + boxShadow: '0 2px 4px rgba(0,0,0,0.05)', + '&:hover': { bgcolor: THEME_COLORS.primary, color: '#fff' }, + }, + LIVE_CLOCK_DIVIDER: { + mx: 0.5, + my: 1, + borderColor: alpha(THEME_COLORS.primary, 0.1), + }, + /** ResultView 样式 */ + RESULT_LABEL: { + color: 'text.secondary', + mb: 1.2, + display: 'block', + fontWeight: 800, + fontSize: '0.7rem', + }, + RESULT_MAIN_BOX: { + bgcolor: alpha(THEME_COLORS.primary, 0.05), + p: 2, + borderRadius: 4, + position: 'relative', + mb: 2.5, + border: '1px solid', + borderColor: alpha(THEME_COLORS.primary, 0.1), + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + RESULT_MAIN_TEXT: { + fontFamily: 'monospace', + fontWeight: 700, + color: THEME_COLORS.primary, + wordBreak: 'break-all', + pr: 4, + fontSize: '1rem', + }, + RESULT_EXTRA_STACK: { + bgcolor: alpha(THEME_COLORS.primary, 0.05), + p: 2, + borderRadius: 4, + border: '1px solid', + borderColor: alpha(THEME_COLORS.primary, 0.1), + }, + RESULT_EXTRA_LABEL: { + color: 'text.disabled', + fontWeight: 700, + fontSize: '0.65rem', + pr: 4, + }, + RESULT_EXTRA_VALUE: { + fontFamily: 'monospace', + color: THEME_COLORS.primary, + fontWeight: 600, + fontSize: '0.65rem', + }, } as const; /** @@ -123,6 +283,260 @@ export const storageCleanerPageStyles = { 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), + /** 选项网格容器 */ + OPTIONS_GRID_CONTAINER: { + mb: 3, + border: '1px solid', + borderColor: 'grey.100', + borderRadius: 4, + bgcolor: 'background.paper', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)', + transition: 'all 0.2s', + overflow: 'hidden', + '&:hover': { + boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)', + }, + }, + OPTIONS_GRID_FOOTER: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + px: 2.7, + py: 0.8, + borderBottomLeftRadius: 4, + borderBottomRightRadius: 4, + transition: 'all 0.2s', + '&:hover': { + bgcolor: 'rgba(0, 0, 0, 0.04)', + }, + }, + OPTIONS_GRID_CHECKBOX: { + p: 0.6, + mr: 0, + '& .MuiSvgIcon-root': { + fontSize: 18, + transition: 'transform 0.2s', + }, + '&:hover .MuiSvgIcon-root': { + transform: 'scale(1.1)', + }, + }, + /** 选项项 */ + OPTION_ITEM: (checked: boolean) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + py: 1, + px: { xs: 1, sm: 1.5 }, + borderRadius: 3, + transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', + bgcolor: checked ? alpha(THEME_COLORS.warning, 0.05) : 'transparent', + border: `1px solid ${checked ? alpha(THEME_COLORS.warning, 0.2) : 'transparent'}`, + '&:hover': { + bgcolor: checked ? alpha(THEME_COLORS.warning, 0.1) : 'rgba(0, 0, 0, 0.02)', + transform: 'translateY(-1px)', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', + }, + }), + OPTION_ITEM_LABEL: (checked: boolean) => ({ + fontSize: '0.75rem', + display: 'block', + lineHeight: 1.2, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + transition: 'color 0.2s', + color: checked ? THEME_COLORS.warning : 'text.primary', + }), + OPTION_ITEM_SIZE: { + color: 'text.secondary', + fontSize: '0.65rem', + fontWeight: 600, + display: 'block', + mt: 0.3, + lineHeight: 1, + whiteSpace: 'nowrap', + opacity: 0.8, + }, + OPTION_ITEM_NO_DATA: { + color: 'grey.400', + fontSize: '0.65rem', + fontWeight: 500, + display: 'block', + mt: 0.3, + lineHeight: 1, + fontStyle: 'italic', + }, + OPTION_ITEM_CHECKBOX: { + p: 0.6, + '& .MuiSvgIcon-root': { + fontSize: 18, + transition: 'transform 0.2s', + }, + '&:hover .MuiSvgIcon-root': { + transform: 'scale(1.1)', + }, + }, + /** 自动刷新切换 */ + AUTO_REFRESH_CONTAINER: { + mb: 3, + p: 1.5, + borderRadius: 4, + bgcolor: 'background.paper', + border: '1px solid', + borderColor: 'grey.100', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)', + transition: 'all 0.2s', + '&:hover': { + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)', + }, + }, + AUTO_REFRESH_SWITCH: { + '& .MuiSwitch-track': { + borderRadius: 20, + }, + '& .MuiSwitch-thumb': { + boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)', + transition: 'all 0.2s', + }, + '&:hover .MuiSwitch-thumb': { + transform: 'scale(1.1)', + }, + }, + /** DomainHeader */ + DOMAIN_HEADER_BADGE: { + bgcolor: alpha(THEME_COLORS.warning, 0.15), + color: THEME_COLORS.warning, + px: 1.5, + py: 0.3, + borderRadius: 2, + fontWeight: 800, + fontSize: '0.7rem', + boxShadow: `0 2px 4px ${alpha(THEME_COLORS.warning, 0.2)}`, + transition: 'all 0.2s', + '&:hover': { + bgcolor: alpha(THEME_COLORS.warning, 0.25), + }, + }, + DOMAIN_HEADER_ICON: { + p: 1.2, + borderRadius: 3, + boxShadow: `0 2px 8px ${alpha(THEME_COLORS.warning, 0.15)}`, + transition: 'all 0.2s', + '&:hover': { + bgcolor: alpha(THEME_COLORS.warning, 0.15), + transform: 'scale(1.05)', + }, + }, + /** ErrorDisplay */ + ERROR_DISPLAY_CONTAINER: { + py: 8, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: { xs: 'auto', sm: '400px' }, + textAlign: 'center', + }, + ERROR_DISPLAY_BOX: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 4, + p: 4, + boxShadow: `0 8px 24px ${alpha(THEME_COLORS.error, 0.15)}`, + border: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`, + bgcolor: alpha(THEME_COLORS.error, 0.05), + }, + /** CleaningResult */ + CLEANING_RESULT_ALERT: { + borderRadius: 3, + py: 1, + px: 2, + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)', + '& .MuiAlert-message': { + fontSize: '0.8rem', + fontWeight: 600, + lineHeight: 1.4, + }, + '& .MuiAlert-icon': { + fontSize: '1.2rem', + mr: 1, + }, + }, + /** StorageCleanerConfirm Dialog */ + CONFIRM_DIALOG_PAPER: { + borderRadius: 6, + backgroundImage: 'none', + boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`, + p: 1.5, + bgcolor: 'background.paper', + }, + CONFIRM_DIALOG_TITLE: { + textAlign: 'center', + pt: 4, + pb: 1, + fontWeight: 900, + letterSpacing: '-0.5px', + fontSize: '1.35rem', + color: 'text.primary', + }, + CONFIRM_DIALOG_CONTENT: { + textAlign: 'center', + pb: 2, + }, + CONFIRM_DIALOG_DESC: { + mb: 3.5, + fontWeight: 500, + fontSize: '0.9rem', + }, + CONFIRM_DIALOG_CHIP: { + bgcolor: alpha(THEME_COLORS.warning, 0.04), + fontWeight: 700, + color: THEME_COLORS.warning, + fontSize: '0.75rem', + border: '1px solid', + borderColor: alpha(THEME_COLORS.warning, 0.15), + borderRadius: 2.5, + height: 'auto', + '& .MuiChip-label': { px: 1.2, py: 0.6 }, + }, + CONFIRM_DIALOG_WARNING_BOX: { + display: 'inline-flex', + alignItems: 'center', + gap: 1, + bgcolor: alpha(THEME_COLORS.error, 0.05), + color: THEME_COLORS.error, + px: 2, + py: 0.8, + borderRadius: 3, + border: '1px dashed', + borderColor: alpha(THEME_COLORS.error, 0.2), + }, + CONFIRM_DIALOG_WARNING_TEXT: { + fontWeight: 800, + display: 'flex', + alignItems: 'center', + gap: 0.5, + fontSize: '0.75rem', + }, + CONFIRM_DIALOG_CANCEL: { + boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)', + color: 'text.secondary', + '&:hover': { + bgcolor: 'grey.100', + color: 'text.primary', + }, + }, + CONFIRM_DIALOG_CONFIRM: { + bgcolor: THEME_COLORS.warning, + '&:hover': { + bgcolor: THEME_COLORS.warningDark, + }, + }, } as const; /** @@ -136,6 +550,149 @@ export const qrCodePageStyles = { successDark: THEME_COLORS.successDark, white: THEME_COLORS.white, black: THEME_COLORS.black, + /** 加载状态容器 */ + LOADING_CONTAINER: { + py: 4, + maxWidth: 400, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: 200, + } as const, + /** Accordion 容器 */ + ACCORDION: { + borderRadius: 4, + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', + '&:before': { display: 'none' }, + } as const, + ACCORDION_SUMMARY: { + borderBottom: 'none', + } as const, + ACCORDION_TITLE_ICON: { + display: 'flex', + alignItems: 'center', + gap: 2, + } as const, + ACCORDION_TITLE_TEXT: { + fontWeight: 700, + } as const, + /** 主操作按钮(生成/解析) */ + PRIMARY_BUTTON: { + py: 1.2, + borderRadius: 3, + bgcolor: THEME_COLORS.success, + fontWeight: 700, + '&:hover': { + bgcolor: THEME_COLORS.successDark, + }, + } as const, + /** 二维码展示区域 */ + QR_PREVIEW_CONTAINER: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + minHeight: 200, + border: '2px dashed', + borderColor: 'grey.200', + borderRadius: 3, + p: 2, + bgcolor: 'grey.50', + } as const, + QR_PREVIEW_INNER: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: '100%', + } as const, + QR_PREVIEW_IMAGE: { + maxWidth: '100%', + height: 'auto', + display: 'block', + } as const, + QR_PREVIEW_ACTIONS: { + display: 'flex', + gap: 1, + mt: 2, + } as const, + /** 下载按钮 */ + DOWNLOAD_BUTTON: { + borderRadius: 2, + borderColor: THEME_COLORS.success, + color: THEME_COLORS.success, + '&:hover': { + borderColor: THEME_COLORS.successDark, + bgcolor: alpha(THEME_COLORS.success, 0.05), + }, + } as const, + /** 复制按钮 */ + COPY_BUTTON: { + borderRadius: 2, + bgcolor: THEME_COLORS.success, + '&:hover': { + bgcolor: THEME_COLORS.successDark, + }, + } as const, + /** 拖拽上传区域 */ + DROPZONE: (dragging: boolean, hasFile: boolean) => + ({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: 200, + border: '2px dashed', + borderColor: dragging || hasFile ? THEME_COLORS.success : 'grey.200', + borderRadius: 3, + p: 4, + bgcolor: dragging + ? alpha(THEME_COLORS.success, 0.1) + : hasFile + ? alpha(THEME_COLORS.success, 0.05) + : 'grey.50', + cursor: 'pointer', + transition: 'all 0.2s', + '&:hover': { + borderColor: THEME_COLORS.success, + bgcolor: alpha(THEME_COLORS.success, 0.05), + }, + }) as const, + /** 图片预览容器 */ + IMAGE_PREVIEW_WRAPPER: { + textAlign: 'center', + width: '100%', + position: 'relative', + } as const, + IMAGE_PREVIEW_BOX: { + position: 'relative', + display: 'inline-block', + } as const, + IMAGE_PREVIEW_IMG: { + maxWidth: '100%', + maxHeight: 160, + borderRadius: 8, + objectFit: 'contain', + } as const, + /** 清除按钮 */ + CLEAR_BUTTON: { + position: 'absolute', + top: -8, + right: -8, + bgcolor: alpha(THEME_COLORS.error, 0.9), + color: 'white', + '&:hover': { + bgcolor: alpha(THEME_COLORS.errorDark, 0.95), + }, + } as const, + /** 结果输入框 */ + RESULT_INPUT: { + position: 'relative', + mt: 2, + } as const, + /** 提示文本 */ + PLACEHOLDER_TEXT: { + textAlign: 'center', + } as const, INPUT_STYLE: {}, } as const; @@ -146,6 +703,15 @@ export const dashboardPageStyles = { primaryColor: THEME_COLORS.primary, backgroundColor: '#f5f5f5', cardBackgroundColor: '#ffffff', + GRID_CONTAINER: { + display: 'grid', + gridTemplateColumns: { + xs: '1fr', + sm: 'repeat(auto-fill, minmax(300px, 1fr))', + }, + gap: 2, + p: 2, + }, } as const; /** @@ -184,6 +750,21 @@ export const textStatisticsPageStyles = { cardBorder: alpha(THEME_COLORS.purple, 0.1), } as const; +/** + * JWT 解析工具页面样式 + */ +/** + * TopBar 组件样式 + */ +export const topBarStyles = { + SEARCH_MAX_WIDTH: 400, + DROPDOWN_MAX_HEIGHT: 300, + Z_INDEX: 1100, + DROPDOWN_Z_INDEX: 1200, + SEARCH_HISTORY_LIMIT: 10, + SEARCH_HISTORY_DISPLAY: 5, +} as const; + /** * JWT 解析工具页面样式 */ diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 791bd12..31d7046 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -1,17 +1,17 @@ -import { useState, useEffect, useMemo } from 'react'; +import { SyntheticEvent, useEffect, useMemo, useState } from 'react'; import { + alpha, Box, - Typography, - Paper, - Switch, Button, CircularProgress, - Stack, - IconButton, - Tabs, - Tab, - alpha, Divider, + IconButton, + Paper, + Stack, + Switch, + Tab, + Tabs, + Typography, } from '@mui/material'; import SettingsIcon from '@mui/icons-material/Settings'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -21,14 +21,15 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import type { PageType, StorageSchema } from '@/types/storage'; import { storageUtil } from '@/utils/chromeStorage'; import { - getFeatureByKey, getDefaultPageOrder, getDefaultVisibleFeatureKeys, + getFeatureByKey, } from '@/config/features'; import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import ErrorBoundary from '@/components/ErrorBoundary'; import PageHeader from '@/components/PageHeader'; import { THEME_COLORS } from '@/config/pageTheme'; +import { useTranslation } from 'react-i18next'; type WindowType = 'popup' | 'sidepanel' | 'tab'; @@ -37,6 +38,7 @@ type WindowType = 'popup' | 'sidepanel' | 'tab'; * 支持对不同窗口入口的功能显示和排序进行独立配置 */ export default function App() { + const { t } = useTranslation(['features', 'common']); // 从 URL 参数中初始化当前的 Tab 类型 const initialWindowType = useMemo(() => { if (typeof window === 'undefined') return 'popup'; @@ -127,7 +129,8 @@ export default function App() { await storageUtil.set(configKeys.visible, newPages); setVisiblePages(newPages); const feature = getFeatureByKey(page); - showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success'); + const label = feature ? t(feature.labelKey) : page; + showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success'); } catch (error) { console.error('Failed to save config:', error); showToast('保存失败', 'warning'); @@ -176,7 +179,7 @@ export default function App() { } }; - const handleWindowTypeChange = (_event: React.SyntheticEvent, newType: WindowType) => { + const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => { if (newType !== null) { setWindowType(newType); } @@ -186,6 +189,16 @@ export default function App() { showMessage(message, { severity }); }; + if (!isLoaded) { + return ( + + + + ); + } + return ( - {feature.label} + {t(feature.labelKey)} - {feature.description || '暂无描述'} + {feature.descriptionKey ? t(feature.descriptionKey) : '暂无描述'} diff --git a/entrypoints/options/main.tsx b/entrypoints/options/main.tsx index f9270cf..9be295d 100644 --- a/entrypoints/options/main.tsx +++ b/entrypoints/options/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import theme from '@/config/theme'; +import '@/i18n'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/entrypoints/popup/main.tsx b/entrypoints/popup/main.tsx index 6494c73..5a429fb 100644 --- a/entrypoints/popup/main.tsx +++ b/entrypoints/popup/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import theme from '@/config/theme'; +import '@/i18n'; import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx index 6494c73..5a429fb 100644 --- a/entrypoints/sidepanel/main.tsx +++ b/entrypoints/sidepanel/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { ThemeProvider } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import theme from '@/config/theme'; +import '@/i18n'; import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/i18n/index.ts b/i18n/index.ts new file mode 100644 index 0000000..f2be107 --- /dev/null +++ b/i18n/index.ts @@ -0,0 +1,138 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { storageUtil } from '@/utils/chromeStorage'; +import dayjs from 'dayjs'; + +// 导入语言文件 +import commonZh from './locales/zh/common.json'; +import featuresZh from './locales/zh/features.json'; +import commonEn from './locales/en/common.json'; +import featuresEn from './locales/en/features.json'; +import timestampZh from './locales/zh/timestamp.json'; +import timestampEn from './locales/en/timestamp.json'; +import storageCleanerZh from './locales/zh/storageCleaner.json'; +import storageCleanerEn from './locales/en/storageCleaner.json'; +import qrCodeZh from './locales/zh/qrCode.json'; +import qrCodeEn from './locales/en/qrCode.json'; +import textStatisticsZh from './locales/zh/textStatistics.json'; +import textStatisticsEn from './locales/en/textStatistics.json'; +import jwtZh from './locales/zh/jwt.json'; +import jwtEn from './locales/en/jwt.json'; + +const resources = { + zh: { + common: commonZh, + features: featuresZh, + timestamp: timestampZh, + storageCleaner: storageCleanerZh, + qrCode: qrCodeZh, + textStatistics: textStatisticsZh, + jwt: jwtZh, + }, + en: { + common: commonEn, + features: featuresEn, + timestamp: timestampEn, + storageCleaner: storageCleanerEn, + qrCode: qrCodeEn, + textStatistics: textStatisticsEn, + jwt: jwtEn, + }, +}; + +export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const; +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +const LANGUAGE_STORAGE_KEY = 'app/language'; +const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language'; + +/** + * 将任意语言标识归一化为受支持的语言代码 + */ +export const normalizeLanguage = (lng: string): SupportedLanguage => { + return lng.startsWith('zh') ? 'zh' : 'en'; +}; + +/** + * 校验语言是否受支持 + */ +const isValidLanguage = (lng: unknown): lng is SupportedLanguage => { + return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng); +}; + +/** + * 同步从 localStorage 获取语言快照(用于消除异步加载产生的首屏闪烁) + */ +const getSyncLanguageSnapshot = (): SupportedLanguage | null => { + try { + const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY); + if (!val) return null; + const parsed = JSON.parse(val) as unknown; + return isValidLanguage(parsed) ? parsed : null; + } catch (error) { + console.error('解析语言同步快照失败:', error); + return null; + } +}; + +// 自定义 Chrome Storage 探测器 +const chromeStorageDetector = { + name: 'chromeStorage', + lookup() { + // 同步初始化已通过 getSyncLanguageSnapshot + init 的 lng 参数处理 + return undefined; + }, + cacheUserLanguage(lng: string) { + storageUtil.set(LANGUAGE_STORAGE_KEY, lng); + }, +}; + +const detector = new LanguageDetector(); +detector.addDetector(chromeStorageDetector); + +const syncLng = getSyncLanguageSnapshot(); + +i18n + .use(detector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + lng: syncLng || undefined, + ns: ['common', 'features', 'timestamp', 'storageCleaner', 'qrCode', 'textStatistics', 'jwt'], + defaultNS: 'common', + debug: false, + interpolation: { + escapeValue: false, + }, + detection: { + order: ['chromeStorage', 'navigator'], + caches: ['chromeStorage'], + }, + }); + +// 监听语言变化:同步 Day.js 和 localStorage 快照 +i18n.on('languageChanged', (lng) => { + const normalizedLng = normalizeLanguage(lng); + dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en'); + localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng)); +}); + +// 初始化时从存储中恢复语言 +storageUtil.get(LANGUAGE_STORAGE_KEY).then((lng) => { + const targetLng = lng || syncLng; + + if (targetLng && isValidLanguage(targetLng) && targetLng !== i18n.language) { + i18n.changeLanguage(targetLng); + } else if (!targetLng) { + // 第一次运行,归一化并持久化初始语言 + const initialLng = normalizeLanguage(i18n.language); + storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng); + if (initialLng !== i18n.language) { + i18n.changeLanguage(initialLng); + } + } +}); + +export default i18n; diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json new file mode 100644 index 0000000..fa2f59d --- /dev/null +++ b/i18n/locales/en/common.json @@ -0,0 +1,23 @@ +{ + "appName": "Testing Tools", + "buttons": { + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "copy": "Copy", + "clear": "Clear", + "refresh": "Refresh", + "toggleLanguage": "Switch Language", + "search": "Search tools...", + "back": "Back", + "clearSearch": "Clear search", + "recentSearch": "Recent search", + "noResults": "No tools found", + "openInTab": "Open in tab", + "settings": "Settings" + }, + "messages": { + "copySuccess": "Copied to clipboard", + "copyError": "Copy failed" + } +} diff --git a/i18n/locales/en/features.json b/i18n/locales/en/features.json new file mode 100644 index 0000000..302c76c --- /dev/null +++ b/i18n/locales/en/features.json @@ -0,0 +1,25 @@ +{ + "dashboard": { + "title": "Dashboard" + }, + "timestamp": { + "title": "Timestamp", + "description": "Unix millisecond conversion and formatting" + }, + "storageCleaner": { + "title": "Storage Cleaner", + "description": "Clean cache, cookies, and local storage" + }, + "qrCode": { + "title": "QR Code Tools", + "description": "Generate QR code for the selected URL" + }, + "textStatistics": { + "title": "Text Statistics", + "description": "Real-time analysis of text characters, words, and bytes" + }, + "jwt": { + "title": "JWT Parser", + "description": "JSON Web Token decoding and viewing" + } +} diff --git a/i18n/locales/en/jwt.json b/i18n/locales/en/jwt.json new file mode 100644 index 0000000..8e5f682 --- /dev/null +++ b/i18n/locales/en/jwt.json @@ -0,0 +1,10 @@ +{ + "pageTitle": "JWT Parser", + "pageSubtitle": "JSON Web Token decoding and viewing", + "placeholder": "Paste Encoded JWT here...", + "headerTitle": "HEADER: Algorithm & Token Type", + "payloadTitle": "PAYLOAD: Data", + "signatureTitle": "Signature", + "noSignature": "No Signature", + "invalidFormat": "Unable to parse" +} diff --git a/i18n/locales/en/qrCode.json b/i18n/locales/en/qrCode.json new file mode 100644 index 0000000..a1098e1 --- /dev/null +++ b/i18n/locales/en/qrCode.json @@ -0,0 +1,31 @@ +{ + "pageTitle": "QR Code Tools", + "pageSubtitle": "Generate and parse QR codes", + "urlToQr": "URL to QR Code", + "qrToUrl": "QR Code to URL", + "urlInputLabel": "Enter URL", + "urlInputPlaceholder": "https://example.com", + "generateButton": "Generate QR Code", + "generating": "Generating...", + "qrCodeWillShow": "QR code will be shown here", + "downloadButton": "Download QR Code", + "copyQrButton": "Copy QR Code Image", + "qrCodeSuccess": "QR code generated successfully", + "qrCodeDownloadSuccess": "QR code downloaded successfully", + "qrCodeCopySuccess": "QR code copied to clipboard", + "selectImage": "Please select a QR code image", + "parseSuccess": "QR code parsed successfully", + "noQrDetected": "No QR code detected", + "parseError": "Failed to parse QR code, please try again", + "imagePasted": "Image pasted successfully", + "imagePasteError": "Failed to paste image, please try again", + "imageCleared": "Image cleared", + "clickToUpload": "Click, drag, or paste to upload QR code image", + "supportFormats": "Supports PNG, JPG, WEBP formats", + "parseButton": "Parse QR Code", + "parsing": "Parsing...", + "resultLabel": "Parsing Result", + "copyTooltip": "Copy", + "enterUrlError": "Please enter a URL", + "clickToChange": "Click to change image" +} diff --git a/i18n/locales/en/storageCleaner.json b/i18n/locales/en/storageCleaner.json new file mode 100644 index 0000000..17d0a95 --- /dev/null +++ b/i18n/locales/en/storageCleaner.json @@ -0,0 +1,33 @@ +{ + "pageTitle": "Storage Cleaner", + "pageSubtitle": "Clear cache, cookies, and local storage", + "loading": "Loading...", + "occupied": "Occupied {{size}}", + "cleaning": "Cleaning...", + "cleanNow": "Clean Now", + "autoRefresh": "Auto refresh page after cleaning", + "selectAll": "Select all items", + "cleanSuccess": "Cleaning complete", + "cleanError": "Cleaning failed", + "noData": "No data", + "countUnit": "items", + "errorNoTab": "Unable to get current tab", + "errorRestricted": "Storage cleaning is not supported on this page", + "cleanSuccessReload": "Cleaning successful, reloading page...", + "errorStandardOnly": "Storage cleaning only works on standard web pages", + "confirmTitle": "Confirm Clear Data?", + "confirmDesc": "You are about to permanently delete the following selected storage items from the current page.", + "irreversible": "This action is irreversible", + "confirmAction": "Confirm Clear", + "cleanedSummary": "Cleaned {{items}}", + "noDataToClean": "No storage data found to clean", + "partialFailure": "Some items failed to clean", + "options": { + "localStorage": "Local Storage", + "sessionStorage": "Session Storage", + "indexedDB": "IndexedDB", + "cookies": "Cookies", + "cacheStorage": "Cache Storage", + "serviceWorkers": "Service Workers" + } +} diff --git a/i18n/locales/en/textStatistics.json b/i18n/locales/en/textStatistics.json new file mode 100644 index 0000000..7fb8f7d --- /dev/null +++ b/i18n/locales/en/textStatistics.json @@ -0,0 +1,9 @@ +{ + "pageTitle": "Text Statistics", + "pageSubtitle": "Real-time analysis of characters, words, lines, and byte size", + "placeholder": "Type or paste text here...", + "characters": "Characters", + "words": "Words", + "lines": "Lines", + "bytes": "Byte Size" +} diff --git a/i18n/locales/en/timestamp.json b/i18n/locales/en/timestamp.json new file mode 100644 index 0000000..55351ab --- /dev/null +++ b/i18n/locales/en/timestamp.json @@ -0,0 +1,26 @@ +{ + "pageTitle": "Timestamp Conversion", + "pageSubtitle": "Unix millisecond conversion and formatting", + "tsToDate": "Timestamp → Date", + "dateToTs": "Date → Timestamp", + "placeholderTs": "Enter timestamp...", + "placeholderDate": "YYYY-MM-DD HH:mm:ss", + "unitMs": "Millisecond (ms)", + "unitS": "Second (s)", + "convertButton": "Convert Now", + "currentTs": "Current Timestamp", + "useNowTooltip": "Use this value", + "copyTsTooltip": "Copy timestamp", + "usedSuccess": "Using current timestamp", + "resultLabel": "Conversion Result", + "copyResultTooltip": "Copy result", + "relativeTime": "Relative Time", + "iso8601": "ISO 8601", + "utcTime": "UTC Time", + "copyTooltip": "Copy", + "errors": { + "invalidNumber": "Invalid number", + "invalidTimestamp": "Invalid timestamp", + "invalidFormat": "Format error" + } +} diff --git a/i18n/locales/zh/common.json b/i18n/locales/zh/common.json new file mode 100644 index 0000000..e49b770 --- /dev/null +++ b/i18n/locales/zh/common.json @@ -0,0 +1,23 @@ +{ + "appName": "测试工具", + "buttons": { + "save": "保存", + "cancel": "取消", + "confirm": "确认", + "copy": "复制", + "clear": "清理", + "refresh": "刷新", + "toggleLanguage": "切换语言", + "search": "搜索工具...", + "back": "返回", + "clearSearch": "清除搜索", + "recentSearch": "最近搜索", + "noResults": "未找到相关工具", + "openInTab": "在标签页打开", + "settings": "设置" + }, + "messages": { + "copySuccess": "已复制到剪贴板", + "copyError": "复制失败" + } +} diff --git a/i18n/locales/zh/features.json b/i18n/locales/zh/features.json new file mode 100644 index 0000000..72a8f6e --- /dev/null +++ b/i18n/locales/zh/features.json @@ -0,0 +1,25 @@ +{ + "dashboard": { + "title": "仪表盘" + }, + "timestamp": { + "title": "时间戳", + "description": "Unix 毫秒数转换与格式化" + }, + "storageCleaner": { + "title": "存储清理", + "description": "清理缓存、Cookies 及本地存储" + }, + "qrCode": { + "title": "二维码工具", + "description": "生成当前选中的 URL 的二维码" + }, + "textStatistics": { + "title": "文本统计", + "description": "实时分析文本字符、单词及字节" + }, + "jwt": { + "title": "JWT 解析", + "description": "JSON Web Token 解码与查看" + } +} diff --git a/i18n/locales/zh/jwt.json b/i18n/locales/zh/jwt.json new file mode 100644 index 0000000..4424528 --- /dev/null +++ b/i18n/locales/zh/jwt.json @@ -0,0 +1,10 @@ +{ + "pageTitle": "JWT 解析", + "pageSubtitle": "JSON Web Token 解码与查看", + "placeholder": "在此粘贴 JWT 令牌 (Encoded JWT)...", + "headerTitle": "HEADER: 算法 & 令牌类型", + "payloadTitle": "PAYLOAD: 数据", + "signatureTitle": "签名", + "noSignature": "无签名", + "invalidFormat": "无法解析" +} diff --git a/i18n/locales/zh/qrCode.json b/i18n/locales/zh/qrCode.json new file mode 100644 index 0000000..f90698a --- /dev/null +++ b/i18n/locales/zh/qrCode.json @@ -0,0 +1,31 @@ +{ + "pageTitle": "二维码工具", + "pageSubtitle": "生成和解析二维码", + "urlToQr": "URL 转二维码", + "qrToUrl": "二维码转 URL", + "urlInputLabel": "输入 URL", + "urlInputPlaceholder": "https://example.com", + "generateButton": "生成二维码", + "generating": "生成中...", + "qrCodeWillShow": "二维码将显示在这里", + "downloadButton": "下载二维码", + "copyQrButton": "复制二维码", + "qrCodeSuccess": "二维码生成成功", + "qrCodeDownloadSuccess": "二维码下载成功", + "qrCodeCopySuccess": "二维码已复制到剪贴板", + "selectImage": "请选择二维码图片", + "parseSuccess": "二维码解析成功", + "noQrDetected": "未检测到二维码", + "parseError": "解析二维码失败,请重试", + "imagePasted": "图片粘贴成功", + "imagePasteError": "粘贴图片失败,请重试", + "imageCleared": "图片已清除", + "clickToUpload": "点击、拖拽或粘贴上传二维码图片", + "supportFormats": "支持 PNG、JPG、WEBP 格式", + "parseButton": "解析二维码", + "parsing": "解析中...", + "resultLabel": "解析结果", + "copyTooltip": "复制", + "enterUrlError": "请输入 URL", + "clickToChange": "点击更换图片" +} diff --git a/i18n/locales/zh/storageCleaner.json b/i18n/locales/zh/storageCleaner.json new file mode 100644 index 0000000..d17fb7b --- /dev/null +++ b/i18n/locales/zh/storageCleaner.json @@ -0,0 +1,33 @@ +{ + "pageTitle": "存储清理", + "pageSubtitle": "清理缓存、Cookies 及本地存储", + "loading": "加载中...", + "occupied": "已占用 {{size}}", + "cleaning": "正在清理...", + "cleanNow": "立即清理", + "autoRefresh": "清理后自动刷新页面", + "selectAll": "全选所有项", + "cleanSuccess": "清理完成", + "cleanError": "清理失败", + "noData": "无数据", + "countUnit": "个", + "errorNoTab": "无法获取当前标签页", + "errorRestricted": "存储清理功能不支持此页面", + "cleanSuccessReload": "清理成功,即将刷新页面", + "errorStandardOnly": "存储清理功能仅适用于标准网页", + "confirmTitle": "确认清理数据?", + "confirmDesc": "您将永久删除当前页面的以下选定存储项。", + "irreversible": "此操作不可撤销", + "confirmAction": "确认清理", + "cleanedSummary": "清理了 {{items}}", + "noDataToClean": "该页面没有可清理的存储数据", + "partialFailure": "部分清理失败", + "options": { + "localStorage": "Local Storage", + "sessionStorage": "Session Storage", + "indexedDB": "IndexedDB", + "cookies": "Cookies", + "cacheStorage": "Cache Storage", + "serviceWorkers": "Service Workers" + } +} diff --git a/i18n/locales/zh/textStatistics.json b/i18n/locales/zh/textStatistics.json new file mode 100644 index 0000000..e8efba4 --- /dev/null +++ b/i18n/locales/zh/textStatistics.json @@ -0,0 +1,9 @@ +{ + "pageTitle": "文本统计", + "pageSubtitle": "实时分析文本的字符、单词、行数及字节大小", + "placeholder": "在此输入或粘贴文本...", + "characters": "字符数", + "words": "单词数", + "lines": "行数", + "bytes": "字节大小" +} diff --git a/i18n/locales/zh/timestamp.json b/i18n/locales/zh/timestamp.json new file mode 100644 index 0000000..aa1fede --- /dev/null +++ b/i18n/locales/zh/timestamp.json @@ -0,0 +1,26 @@ +{ + "pageTitle": "时间戳转换", + "pageSubtitle": "Unix 毫秒数转换与格式化", + "tsToDate": "时间戳 → 日期", + "dateToTs": "日期 → 时间戳", + "placeholderTs": "输入时间戳...", + "placeholderDate": "YYYY-MM-DD HH:mm:ss", + "unitMs": "毫秒 (ms)", + "unitS": "秒 (s)", + "convertButton": "立即转换", + "currentTs": "当前时间戳", + "useNowTooltip": "填充到下方", + "copyTsTooltip": "复制时间戳", + "usedSuccess": "已使用当前时间戳", + "resultLabel": "转换结果", + "copyResultTooltip": "复制结果", + "relativeTime": "相对时间", + "iso8601": "ISO 8601", + "utcTime": "UTC 时间", + "copyTooltip": "复制", + "errors": { + "invalidNumber": "无效数字", + "invalidTimestamp": "无效时间戳", + "invalidFormat": "格式错误" + } +} diff --git a/package-lock.json b/package-lock.json index 362e3d2..32ac84a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,10 +16,13 @@ "@mui/material": "^7.3.8", "@webext-core/messaging": "^2.3.0", "dayjs": "^1.11.19", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", "qr-scanner": "^1.4.2", "qrious": "^4.0.2", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "react-i18next": "^17.0.6" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.0", @@ -6188,6 +6191,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -6265,6 +6277,43 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "26.0.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", + "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8821,6 +8870,42 @@ "react": "^19.2.3" } }, + "node_modules/react-i18next": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", + "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-i18next/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -10197,7 +10282,7 @@ "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10431,6 +10516,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11173,6 +11267,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 15d4de3..b23fcea 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "lint": "eslint . --max-warnings=0", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -27,10 +28,13 @@ "@mui/material": "^7.3.8", "@webext-core/messaging": "^2.3.0", "dayjs": "^1.11.19", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", "qr-scanner": "^1.4.2", "qrious": "^4.0.2", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "react-i18next": "^17.0.6" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.0", diff --git a/pages/Dashboard/ToolCard.tsx b/pages/Dashboard/ToolCard.tsx new file mode 100644 index 0000000..5a93c75 --- /dev/null +++ b/pages/Dashboard/ToolCard.tsx @@ -0,0 +1,152 @@ +/** + * ToolCard 组件 - 工具卡片 + * + * 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、 + * 快照内容展示,具备悬停动画效果。 + */ +import { alpha, Box, Card, CardActionArea, Stack, Typography } from '@mui/material'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import React from 'react'; + +/** + * ToolCard 组件属性接口 + */ +interface ToolCardProps { + /** 工具卡片标题 */ + title: string; + /** 工具卡片描述文本(可选) */ + description?: string; + /** 快照内容,用于在卡片底部展示额外信息(可选) */ + snapshot?: React.ReactNode; + /** 主题色代码,用于图标背景和悬停效果 */ + colorCode: string; + /** 工具图标元素 */ + icon: React.ReactNode; + /** 卡片点击事件处理函数 */ + onClick: () => void; +} + +/** + * ToolCard 组件 + * + * @param props - ToolCardProps 属性对象 + * @returns 工具卡片 JSX 元素 + */ +export default function ToolCard({ + title, + description, + snapshot, + colorCode, + icon, + onClick, +}: ToolCardProps) { + return ( + + + + + + {icon} + + + + {title} + + {description && ( + + {description} + + )} + + + + + + {snapshot != null && ( + + {snapshot} + + )} + + + ); +} + +ToolCard.displayName = 'ToolCard'; diff --git a/pages/Dashboard/index.tsx b/pages/Dashboard/index.tsx new file mode 100644 index 0000000..7084c18 --- /dev/null +++ b/pages/Dashboard/index.tsx @@ -0,0 +1,37 @@ +import { Box } from '@mui/material'; +import { useRouter } from '@/providers/RouterProvider'; +import ToolCard from '@/pages/Dashboard/ToolCard'; +import { getFeatureByKey } from '@/config/features'; +import type { PageType } from '@/types/storage'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { dashboardPageStyles } from '@/config/pageTheme'; + +export default function DashboardPage() { + const { navigateTo, visiblePages, pageOrder } = useRouter(); + const { t } = useTranslation(['features']); + + const visibleSet = useMemo(() => new Set(visiblePages), [visiblePages]); + + return ( + + {pageOrder.map((key) => { + if (!visibleSet.has(key as PageType)) return null; + + const feature = getFeatureByKey(key); + if (!feature?.themeColor || feature.icon == null) return null; + + return ( + navigateTo(key)} + /> + ); + })} + + ); +} diff --git a/pages/DashboardPage.tsx b/pages/DashboardPage.tsx deleted file mode 100644 index 2df08bf..0000000 --- a/pages/DashboardPage.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { Box } from '@mui/material'; -import { useRouter } from '@/providers/RouterProvider'; -import DashboardCard from '@/components/DashboardCard'; -import { getFeatureByKey } from '@/config/features'; -import type { PageType } from '@/types/storage'; -import { useCallback } from 'react'; - -import { dashboardPageStyles } from '@/config/pageTheme'; - -export default function DashboardPage() { - const { navigateTo, visiblePages, pageOrder } = useRouter(); - - const isVisible = (key: string) => visiblePages.includes(key as PageType); - - const handleCardClick = useCallback( - (page: PageType) => { - navigateTo(page); - }, - [navigateTo], - ); - - return ( - - {pageOrder.map((key) => { - if (!isVisible(key)) return null; - - const feature = getFeatureByKey(key); - if (!feature || !feature.icon || !feature.themeColor) return null; - - // 适配 DashboardCard 组件,将 themeColor 映射到 colorCode - const cardConfig = { - title: feature.label, - description: feature.description, - colorCode: feature.themeColor, - icon: feature.icon, - }; - - return ( - handleCardClick(key)} - cardBackgroundColor={dashboardPageStyles.cardBackgroundColor} - /> - ); - })} - - ); -} diff --git a/pages/JwtPage.tsx b/pages/Jwt/index.tsx similarity index 67% rename from pages/JwtPage.tsx rename to pages/Jwt/index.tsx index 597bd51..c54fdae 100644 --- a/pages/JwtPage.tsx +++ b/pages/Jwt/index.tsx @@ -1,12 +1,13 @@ -import { useState, useMemo } from 'react'; -import { TextField, Stack, Box, Container, Typography, Paper } from '@mui/material'; +import { useMemo, useState } from 'react'; +import { Box, Container, Paper, Stack, TextField, Typography } from '@mui/material'; import { useSnackbar } from '@/components/GlobalSnackbar'; import VpnKeyIcon from '@mui/icons-material/VpnKey'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import PageHeader from '@/components/PageHeader'; import { jwtPageStyles } from '@/config/pageTheme'; -import { parseJwt, formatJson } from '@/utils/jwt'; +import { formatJson, parseJwt } from '@/utils/jwt'; import CopyButton from '@/components/CopyButton'; +import { useTranslation } from 'react-i18next'; interface SectionProps { title: string; @@ -15,47 +16,51 @@ interface SectionProps { color: string; } -const Section = ({ title, content, color }: SectionProps) => ( - - - - {title} - - - - - - { + const { t } = useTranslation(['jwt']); + return ( + - {content ? formatJson(content) : '无法解析'} - - -); + + + {title} + + + + + + + {content ? formatJson(content) : t('jwt:invalidFormat')} + + + ); +}; -export default function JwtPage() { +export default function Index() { useSnackbar(); + const { t } = useTranslation(['jwt']); const [jwtInput, setJwtInput] = useState(''); const result = useMemo(() => { @@ -68,14 +73,18 @@ export default function JwtPage() { return ( - } /> + } + /> {/* Input Area */} { // 自动去除 Bearer 前缀及首尾空白字符/换行 @@ -110,13 +119,13 @@ export default function JwtPage() { {result && !result.error && (
- 签名 + {t('jwt:signatureTitle')} @@ -159,7 +168,7 @@ export default function JwtPage() { border: '1px solid rgba(0,0,0,0.05)', }} > - {result.signature || 'No Signature'} + {result.signature || t('jwt:noSignature')} diff --git a/components/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx similarity index 59% rename from components/QrCodeToUrlSection.tsx rename to pages/QrCode/QrCodeToUrlSection.tsx index 7de059d..5ce767d 100644 --- a/components/QrCodeToUrlSection.tsx +++ b/pages/QrCode/QrCodeToUrlSection.tsx @@ -1,17 +1,17 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { - Box, - Typography, - TextField, - Button, - Stack, - Alert, Accordion, - AccordionSummary, AccordionDetails, + AccordionSummary, + Alert, + Box, + Button, CircularProgress, - InputAdornment, IconButton, + InputAdornment, + Stack, + TextField, + Typography, } from '@mui/material'; import LinkIcon from '@mui/icons-material/Link'; import ImageIcon from '@mui/icons-material/Image'; @@ -19,21 +19,20 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ClearIcon from '@mui/icons-material/Clear'; import CopyButton from '@/components/CopyButton'; import { qrCodePageStyles } from '@/config/pageTheme'; -import type { SnackbarOptions } from '@/components/GlobalSnackbar'; +import { useSnackbar } from '@/components/GlobalSnackbar'; import { parseQrCodeFromFile } from '@/utils/qrCodeParser'; +import { useTranslation } from 'react-i18next'; interface QrCodeToUrlSectionProps { expanded: boolean; onExpandedChange: (expanded: boolean) => void; - showMessage: (message: string, options?: SnackbarOptions) => void; } -const QrCodeToUrlSection = ({ - expanded, - onExpandedChange, - showMessage, -}: QrCodeToUrlSectionProps) => { +const QrCodeToUrlSection = ({ expanded, onExpandedChange }: QrCodeToUrlSectionProps) => { + const { t } = useTranslation(['qrCode']); + const { showMessage } = useSnackbar(); const [qrCodeFile, setQrCodeFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(''); const [parsedUrl, setParsedUrl] = useState(''); const [parseError, setParseError] = useState(''); const [parsing, setParsing] = useState(false); @@ -41,12 +40,36 @@ const QrCodeToUrlSection = ({ const fileInputRef = useRef(null); + // 清理预览 URL,防止内存泄漏 + useEffect(() => { + return () => { + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + const handleFileChange = useCallback((file: File) => { setQrCodeFile(file); + setPreviewUrl(URL.createObjectURL(file)); setParseError(''); setParsedUrl(''); }, []); + const handleClearFile = () => { + setQrCodeFile(null); + if (previewUrl) { + URL.revokeObjectURL(previewUrl); + } + setPreviewUrl(''); + setParsedUrl(''); + setParseError(''); + showMessage(t('qrCode:imageCleared'), { + severity: 'success', + autoHideDuration: 1000, + }); + }; + const handleInputChange = (e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { handleFileChange(e.target.files[0]); @@ -73,7 +96,7 @@ const QrCodeToUrlSection = ({ const parseQrCode = async () => { if (!qrCodeFile) { - showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 }); + showMessage(t('qrCode:selectImage'), { severity: 'error', autoHideDuration: 300 }); return; } @@ -86,16 +109,16 @@ const QrCodeToUrlSection = ({ if (result.success && result.data) { setParsedUrl(result.data); - showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 }); + showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 }); } else { - showMessage(result.error || '未检测到二维码', { + showMessage(result.error || t('qrCode:noQrDetected'), { severity: 'error', autoHideDuration: 1000, }); } } catch (error) { console.error('解析二维码失败:', error); - showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 }); + showMessage(t('qrCode:parseError'), { severity: 'error', autoHideDuration: 300 }); } finally { setParsing(false); } @@ -117,10 +140,13 @@ const QrCodeToUrlSection = ({ if (file) { try { handleFileChange(file); - showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 }); + showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 }); } catch (error) { console.error('处理粘贴图片失败:', error); - showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 }); + showMessage(t('qrCode:imagePasteError'), { + severity: 'error', + autoHideDuration: 3000, + }); } } break; @@ -133,55 +159,26 @@ const QrCodeToUrlSection = ({ return () => { document.removeEventListener('paste', handlePaste); }; - }, [expanded, showMessage, handleFileChange]); + }, [expanded, showMessage, handleFileChange, t]); return ( onExpandedChange(isExpanded)} - sx={{ - borderRadius: 4, - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', - '&:before': { display: 'none' }, - }} + sx={qrCodePageStyles.ACCORDION} > - } sx={{ borderBottom: 'none' }}> - + } sx={qrCodePageStyles.ACCORDION_SUMMARY}> + - - 二维码转 URL + + {t('qrCode:qrToUrl')}