Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 037e98cd45 | |||
| 6abdf4add4 | |||
| a0d6e76cad | |||
| cc6d414d10 | |||
| 5daa151b5e | |||
| e93b5c817f | |||
| d678f14549 | |||
| c95e028482 | |||
| 8ec36ac3b3 | |||
| 262c5e9255 | |||
| e4993cb0ce | |||
| 0d60961a82 | |||
| df3eee8749 | |||
| cf05df7022 | |||
| e0888c7fb3 | |||
| 8b2bca9eef | |||
| 6bac88fbc4 | |||
| 4c08e17b19 | |||
| fa0f1bdcad | |||
| 2d7bb8fc2c | |||
| a0e6871b16 | |||
| 3adfe95234 | |||
| efbed7cf53 | |||
| 364672918d | |||
| 5e23b29a25 | |||
| cc9162f601 | |||
| 67bafe53d5 |
@@ -90,6 +90,9 @@ jobs:
|
|||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- name: Run TypeScript type check
|
- name: Run TypeScript type check
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
@@ -113,6 +116,9 @@ jobs:
|
|||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
@@ -144,6 +150,9 @@ jobs:
|
|||||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
|
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- name: Build Extension (${{ matrix.browser }})
|
- name: Build Extension (${{ matrix.browser }})
|
||||||
run: |
|
run: |
|
||||||
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
||||||
|
|||||||
@@ -9,145 +9,147 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Phase 1: 依赖统一前置基础架构(Infrastructure Stage) ───────────────────
|
# ── Phase 1: 依赖准备 ───────────────────
|
||||||
setup:
|
setup:
|
||||||
name: Prepare Dependencies
|
name: Prepare Dependencies
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
- name: Cache Node Modules
|
|
||||||
id: cache-nodemodules
|
id: cache-nodemodules
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-release-v22-
|
${{ runner.os }}-release-v22-
|
||||||
|
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
- name: Install dependencies
|
|
||||||
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
# ── Phase 2: 全量生产级断言检查(秒级瞬时恢复缓存,安全闭环) ──────────────────
|
# ── Phase 2: 质量检查 ──────────────────
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
- name: Run ESLint
|
- run: npm run lint
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript Check
|
name: TypeScript Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
- name: Run TypeScript type check
|
- run: npx wxt prepare
|
||||||
run: npm run compile
|
- run: npm run typecheck
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: setup
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
- name: Run tests
|
- run: npx wxt prepare
|
||||||
run: npm run test
|
- run: npm run test
|
||||||
|
|
||||||
# ── Phase 3: 多端分布式高精打包(Compile & Upload Artifacts) ───────────────
|
# ── Phase 3: 打包 ───────────────────────
|
||||||
build-extension:
|
build-extension:
|
||||||
name: Package Extension
|
name: Package Extension
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [ lint, typecheck, test ]
|
needs: [lint, typecheck, test]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
- name: Restore Node Modules
|
- uses: actions/cache@v4
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
with:
|
||||||
path: node_modules
|
path: node_modules
|
||||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
# 执行 WXT 高阶打包压缩指令
|
- name: Sync version from tag
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "Setting version to $VERSION"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
|
||||||
- name: Build and Zip Extension
|
- name: Build and Zip Extension
|
||||||
run: |
|
run: |
|
||||||
npm run zip
|
npm run zip
|
||||||
npm run zip:firefox
|
npm run zip:firefox
|
||||||
|
|
||||||
# 💡 核心自愈补丁:显式将 .output 下打包出的真实生产绝对路径文件,
|
# 🔍 调试:确认 zip 文件位置
|
||||||
# 稳固地上存至 GitHub 的常驻产物箱中进行安全物理隔离,防范后期发布网络崩溃导致产物蒸发!
|
- name: Verify output files
|
||||||
|
run: |
|
||||||
|
echo "=== .output/ contents ==="
|
||||||
|
ls -la .output/ || echo "Not found"
|
||||||
|
echo "=== All zip files ==="
|
||||||
|
find . -name "*.zip" -type f
|
||||||
|
|
||||||
|
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
|
||||||
|
- name: Prepare release artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-archives
|
||||||
|
cp .output/*.zip release-archives/ 2>/dev/null || true
|
||||||
|
echo "Files in release-archives:"
|
||||||
|
ls -la release-archives/
|
||||||
|
|
||||||
- name: Upload Extension Artifacts
|
- name: Upload Extension Artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: extension-zips
|
name: extension-zips
|
||||||
path: |
|
path: release-archives/
|
||||||
.output/*.zip
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
# ── Phase 4: 独立中央签发发布(Atomic Release Publisher) ───────────────────
|
# ── Phase 4: 发布 ───────────────────────
|
||||||
release:
|
release:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-extension
|
needs: build-extension
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# 💡 独立下载打包完好的绝对产物包
|
|
||||||
- name: Download Extension Artifacts
|
- name: Download Extension Artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: extension-zips
|
name: extension-zips
|
||||||
path: release-artifacts
|
path: release-artifacts
|
||||||
|
|
||||||
|
# 🔍 调试:确认下载成功
|
||||||
|
- name: Verify downloaded artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== release-artifacts/ ==="
|
||||||
|
ls -la release-artifacts/
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# 💡 最终无风险原子级发布大礼包
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -156,6 +158,5 @@ jobs:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
# 百分之百精准指向被下载下来的、毫无路径污染风险的 Zip 包实体
|
|
||||||
files: |
|
files: |
|
||||||
release-artifacts/*.zip
|
release-artifacts/*.zip
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
WXT 浏览器扩展项目 (React 19 + TypeScript + MUI v7)。
|
WXT 浏览器扩展项目 (React 19 + TypeScript)。
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ npm run dev # Chrome 开发模式 (HMR)
|
|||||||
npm run dev:firefox # Firefox 开发模式
|
npm run dev:firefox # Firefox 开发模式
|
||||||
npm run build # Chrome 生产构建
|
npm run build # Chrome 生产构建
|
||||||
npm run build:firefox # Firefox 生产构建
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
npm run lint # ESLint (--max-warnings=0)
|
npm run lint # ESLint (--max-warnings=0)
|
||||||
npm run typecheck # tsc --noEmit
|
npm run typecheck # tsc --noEmit
|
||||||
npm run test # vitest run (单次执行)
|
npm run test # vitest run (单次执行)
|
||||||
@@ -20,48 +22,57 @@ npm run test:coverage # 带覆盖率的测试
|
|||||||
|
|
||||||
## 验证流程
|
## 验证流程
|
||||||
|
|
||||||
CI 执行顺序: `lint → typecheck → test → build` (build 依赖前三者)。
|
CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。
|
||||||
|
|
||||||
Pre-commit hook (lint-staged) 顺序:
|
Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`):
|
||||||
|
|
||||||
1. `prettier --write`
|
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored` → `prettier --write`
|
||||||
2. `eslint --fix --max-warnings=0`
|
- 其他文件 (`*.{json,css,scss,md}`): `prettier --write`
|
||||||
3. `tsc --noEmit`
|
|
||||||
|
|
||||||
提交前确保三者通过。
|
提交前确保 `lint` 和 `typecheck` 通过。
|
||||||
|
|
||||||
|
## WXT 生成文件
|
||||||
|
|
||||||
|
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
|
||||||
|
- 生产构建输出到 `.output/` 目录。
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||||
config/pageTheme.ts # 页面级主题常量
|
|
||||||
config/theme.ts # MUI 全局主题
|
|
||||||
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
||||||
pages/ # 功能页面组件 (懒加载)
|
pages/ # 功能页面组件 (懒加载)
|
||||||
components/ # 可复用 UI 组件
|
components/ # 可复用 UI 组件
|
||||||
providers/ # React Context (Router, Theme 等)
|
providers/ # React Context (Router, Theme 等)
|
||||||
|
hooks/ # 自定义 React Hooks
|
||||||
utils/ # 工具函数与服务抽象
|
utils/ # 工具函数与服务抽象
|
||||||
types/ # TypeScript 类型声明
|
types/ # TypeScript 类型声明
|
||||||
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
|
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 关键架构决策
|
## 关键架构决策
|
||||||
|
|
||||||
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。
|
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||||
|
渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。
|
||||||
|
|
||||||
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 Hook。
|
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
|
||||||
|
Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||||
|
|
||||||
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
||||||
|
|
||||||
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||||
|
|
||||||
|
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||||
|
|
||||||
## 测试环境
|
## 测试环境
|
||||||
|
|
||||||
- 环境: jsdom
|
- 环境: jsdom
|
||||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||||
- `chrome.*` API (storage, tabs, runtime, cookies 等)
|
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||||
- `react-i18next` (返回 key 作为翻译)
|
- `react-i18next` (返回 key 作为翻译)
|
||||||
|
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
|
||||||
- `window.matchMedia`
|
- `window.matchMedia`
|
||||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
|
|
||||||
@@ -70,14 +81,14 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
|
|||||||
- 命名空间: `common` (默认), `features`
|
- 命名空间: `common` (默认), `features`
|
||||||
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
||||||
- 语言: `zh` (默认), `en`
|
- 语言: `zh` (默认), `en`
|
||||||
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json`
|
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
|
||||||
|
|
||||||
## 新功能开发清单
|
## 新功能开发清单
|
||||||
|
|
||||||
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
||||||
3. 在 `pages/` 创建页面组件 (懒加载)
|
3. 在 `pages/` 创建页面组件 (懒加载)
|
||||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译
|
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
|
||||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
6. 添加对应的单元测试
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
@@ -85,14 +96,16 @@ i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
|
|||||||
|
|
||||||
- 禁止使用 `any` (测试文件除外)
|
- 禁止使用 `any` (测试文件除外)
|
||||||
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||||
- 样式: 复杂页面样式放 `config/pageTheme.ts`,简单样式用 MUI `sx` prop
|
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||||
- 格式: Prettier (100 字符宽, 单引号, 尾逗号)
|
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||||
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||||
|
|
||||||
## 技术栈版本
|
## 技术栈版本
|
||||||
|
|
||||||
- WXT: ^0.20.26
|
- WXT: ^0.20.26
|
||||||
- React: ^19.2.6
|
- React: ^19.2.6
|
||||||
- MUI: ^7.3.8
|
- Tailwind CSS: ^3.4.19
|
||||||
|
- shadcn/ui (基于 Radix UI + class-variance-authority)
|
||||||
- TypeScript: ^5.9.3
|
- TypeScript: ^5.9.3
|
||||||
- Vitest: ^4.1.7
|
- Vitest: ^4.1.7
|
||||||
- i18next: ^26.2.0
|
- i18next: ^26.2.0
|
||||||
|
|||||||
@@ -41,18 +41,41 @@
|
|||||||
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
||||||
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
||||||
|
|
||||||
|
### 🖱️ 右键恢复工具
|
||||||
|
|
||||||
|
- **当前页面检测**: 自动识别当前活动标签页的域名.
|
||||||
|
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
|
||||||
|
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
|
||||||
|
|
||||||
### 🖼️ 二维码工具
|
### 🖼️ 二维码工具
|
||||||
|
|
||||||
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||||
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||||
|
|
||||||
|
### 🔄 JSON 工具
|
||||||
|
|
||||||
|
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
|
||||||
|
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
|
||||||
|
|
||||||
|
### 🧰 Base64 转换器
|
||||||
|
|
||||||
|
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
|
||||||
|
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||||
|
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||||
|
|
||||||
|
### 📝 Markdown ↔ HTML 转换
|
||||||
|
|
||||||
|
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
|
||||||
|
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||||
- **前端**: React 19 + TypeScript
|
- **前端**: React 19 + TypeScript
|
||||||
- **UI 组件**: Material UI (MUI) @7.x
|
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||||
- **样式**: Emotion (Styled Components)
|
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||||
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||||
|
- **国际化**: i18next + react-i18next
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
@@ -62,9 +85,8 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
├── components/ # 可复用 React 组件
|
├── components/ # 可复用 React 组件
|
||||||
├── config/ # 应用配置(路由、功能元数据、主题)
|
├── config/ # 应用配置
|
||||||
│ ├── features.tsx # 功能定义与路由映射
|
│ └── features.tsx # 功能定义与路由映射
|
||||||
│ └── pageTheme.ts # 各功能页面的视觉风格配置
|
|
||||||
├── entrypoints/ # 扩展程序入口点
|
├── entrypoints/ # 扩展程序入口点
|
||||||
│ ├── popup/ # 点击图标弹出的主界面
|
│ ├── popup/ # 点击图标弹出的主界面
|
||||||
│ ├── options/ # 扩展程序设置页面
|
│ ├── options/ # 扩展程序设置页面
|
||||||
@@ -72,10 +94,13 @@
|
|||||||
│ ├── background.ts # 后台 Service Worker
|
│ ├── background.ts # 后台 Service Worker
|
||||||
│ └── content.ts # 网页注入脚本
|
│ └── content.ts # 网页注入脚本
|
||||||
├── pages/ # 各功能模块的页面组件
|
├── pages/ # 各功能模块的页面组件
|
||||||
├── providers/ # 全局状态提供者 (Router, Snackbar 等)
|
├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||||
├── types/ # TypeScript 类型声明
|
├── hooks/ # 自定义 React Hooks
|
||||||
├── utils/ # 工具函数与服务抽象
|
├── utils/ # 工具函数与服务抽象
|
||||||
├── public/ # 静态资源 (图标、 manifest 资源等)
|
├── types/ # TypeScript 类型声明
|
||||||
|
├── lib/ # 通用工具函数 (cn, utils 等)
|
||||||
|
├── i18n/ # 国际化资源
|
||||||
|
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||||
├── wxt.config.ts # WXT 框架核心配置
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
└── package.json # 项目元数据与依赖管理
|
└── package.json # 项目元数据与依赖管理
|
||||||
```
|
```
|
||||||
@@ -90,15 +115,20 @@
|
|||||||
### 常用命令
|
### 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| ----------------------- | -------------------------------- |
|
| ----------------------- | --------------------------------- |
|
||||||
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||||
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||||
| `npm run build` | 构建 Chrome 生产版本 |
|
| `npm run build` | 构建 Chrome 生产版本 |
|
||||||
| `npm run compile` | 执行 TypeScript 类型检查 |
|
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||||
|
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||||
|
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||||
|
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||||
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||||
| `npm run test` | 运行单元测试 |
|
| `npm run test` | 运行单元测试 |
|
||||||
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||||
|
|
||||||
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
### 自动化流程
|
### 自动化流程
|
||||||
|
|
||||||
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||||
@@ -110,12 +140,14 @@
|
|||||||
|
|
||||||
本扩展根据功能需要申请了以下权限:
|
本扩展根据功能需要申请了以下权限:
|
||||||
|
|
||||||
- `storage`: 存储用户设置和工具配置.
|
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||||
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||||
- `scripting`: 在网页中执行清理脚本.
|
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||||
- `cookies`: 管理和清理网站 Cookie.
|
- `cookies`: 管理和清理网站 Cookie.
|
||||||
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
- `clipboardWrite`: 提供一键复制功能.
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||||
|
|
||||||
## 浏览器支持
|
## 浏览器支持
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { Download } from 'lucide-react';
|
import { Download } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { formatFileSize } from '@/utils/base64Converter';
|
import { formatFileSize } from '@/utils/base64Converter';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
interface DecodeResultPaperProps {
|
interface DecodeResultPaperProps {
|
||||||
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
||||||
@@ -38,7 +38,7 @@ export default function DecodeResultPaper({
|
|||||||
onDownload,
|
onDownload,
|
||||||
children,
|
children,
|
||||||
}: DecodeResultPaperProps) {
|
}: DecodeResultPaperProps) {
|
||||||
const { t } = useTranslation('base64Converter');
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { ReactNode, useMemo } from 'react';
|
|
||||||
import { getEntryPointType } from '@/config/features';
|
|
||||||
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
|
||||||
|
|
||||||
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
/** 要显示的图标组件 */
|
|
||||||
icon: ReactNode;
|
|
||||||
/**
|
|
||||||
* 图标的颜色,支持:
|
|
||||||
* 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐
|
|
||||||
* 2. 原生颜色值 (如 '#3b82f6')
|
|
||||||
*/
|
|
||||||
iconColor?: string;
|
|
||||||
/** 主标题文本 */
|
|
||||||
title: string;
|
|
||||||
/** 副标题文本(可选) */
|
|
||||||
subtitle?: string;
|
|
||||||
/** 在标题右侧显示的徽章/标签组件(可选) */
|
|
||||||
badge?: ReactNode;
|
|
||||||
/** 覆盖图标容器的类名 */
|
|
||||||
iconClassName?: string;
|
|
||||||
/** 覆盖主标题的类名 */
|
|
||||||
titleClassName?: string;
|
|
||||||
/** 覆盖副标题的类名 */
|
|
||||||
subtitleClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageHeader({
|
|
||||||
icon,
|
|
||||||
iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6"
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
badge,
|
|
||||||
iconClassName,
|
|
||||||
titleClassName,
|
|
||||||
subtitleClassName,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
const entryPointType = useMemo(() => getEntryPointType(), []);
|
|
||||||
|
|
||||||
// 扩展环境判断:如果是 popup 形式则不渲染头部
|
|
||||||
if (entryPointType === 'popup') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断传入的是否是 Hex/RGB 等原生颜色值
|
|
||||||
const isRawColor =
|
|
||||||
iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center gap-3 mb-6', className)} {...props}>
|
|
||||||
{/* 图标容器 */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'p-2 rounded-lg flex items-center justify-center shrink-0',
|
|
||||||
// 如果不是原生颜色,直接当作 Tailwind 类名注入
|
|
||||||
!isRawColor && iconColor,
|
|
||||||
iconClassName,
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
isRawColor
|
|
||||||
? {
|
|
||||||
color: iconColor,
|
|
||||||
// 使用 CSS inline 变量或 color-mix 安全处理透明度,不再暴力拼接 "15"
|
|
||||||
backgroundColor: `color-mix(in srgb, ${iconColor} 8%, transparent)`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */}
|
|
||||||
<div className="[&>svg]:h-5 [&>svg]:w-5">{icon}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
|
||||||
{/* 标题行(含徽章) */}
|
|
||||||
<div className="flex justify-between items-center gap-2">
|
|
||||||
<h1
|
|
||||||
className={cn(
|
|
||||||
'text-base font-extrabold tracking-tight leading-tight text-foreground truncate',
|
|
||||||
titleClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
{badge && <div className="shrink-0">{badge}</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 副标题 - 使用 p 标签(block)保证换行 */}
|
|
||||||
{subtitle && (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'text-xs font-semibold text-muted-foreground truncate',
|
|
||||||
subtitleClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,7 @@ describe('DecodeResultPaper 组件', () => {
|
|||||||
|
|
||||||
it('应渲染下载按钮', () => {
|
it('应渲染下载按钮', () => {
|
||||||
render(<DecodeResultPaper {...defaultProps} />);
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染 children 内容', () => {
|
it('应渲染 children 内容', () => {
|
||||||
@@ -64,7 +64,7 @@ describe('DecodeResultPaper 组件', () => {
|
|||||||
|
|
||||||
it('点击下载按钮时应调用 onDownload', () => {
|
it('点击下载按钮时应调用 onDownload', () => {
|
||||||
render(<DecodeResultPaper {...defaultProps} />);
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'download' }));
|
fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
|
||||||
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -72,12 +72,12 @@ describe('DecodeResultPaper 组件', () => {
|
|||||||
describe('按钮状态', () => {
|
describe('按钮状态', () => {
|
||||||
it('文件名为空时下载按钮应禁用', () => {
|
it('文件名为空时下载按钮应禁用', () => {
|
||||||
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||||
expect(screen.getByRole('button', { name: 'download' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('文件名不为空时下载按钮应启用', () => {
|
it('文件名不为空时下载按钮应启用', () => {
|
||||||
render(<DecodeResultPaper {...defaultProps} />);
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: 'download' })).toBeEnabled();
|
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,24 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import ImageUploader from '@/components/ImageUploader';
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
|
|
||||||
|
// 配置多端一致性常驻桩(WXT 规范)
|
||||||
|
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||||
|
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
|
||||||
|
// 💡 1. 规范对齐:挂载标准的 react-i18next 统一桩函数,防止多进程前缀破产
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: vi.fn((ns: string | string[]) => {
|
||||||
|
const nsArray = Array.isArray(ns) ? ns : [ns];
|
||||||
|
return {
|
||||||
|
t: (key: string) => `${nsArray.join(',')}:${key}`,
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
// 模拟 URL API
|
// 模拟 URL API
|
||||||
const mockCreateObjectURL = vi.fn();
|
const mockCreateObjectURL = vi.fn();
|
||||||
const mockRevokeObjectURL = vi.fn();
|
const mockRevokeObjectURL = vi.fn();
|
||||||
@@ -44,8 +61,9 @@ describe('ImageUploader 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('当没有选中文件时应显示上传提示', () => {
|
it('当没有选中文件时应显示上传提示', () => {
|
||||||
render(<ImageUploader {...defaultProps} />);
|
render(<ImageUploader {...defaultProps} />);
|
||||||
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
|
// 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
|
||||||
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
|
expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||||
@@ -59,7 +77,8 @@ describe('ImageUploader 组件', () => {
|
|||||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||||
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
|
// 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
|
||||||
|
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当选中文件时应显示预览图片', () => {
|
it('当选中文件时应显示预览图片', () => {
|
||||||
@@ -157,18 +176,6 @@ describe('ImageUploader 组件', () => {
|
|||||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('清除文件时应撤销预览 URL', () => {
|
|
||||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
|
||||||
render(
|
|
||||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
|
||||||
fireEvent.click(clearButton);
|
|
||||||
|
|
||||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('粘贴功能', () => {
|
describe('粘贴功能', () => {
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
|
|
||||||
|
|
||||||
vi.mock('@/config/features', () => ({
|
|
||||||
getEntryPointType: vi.fn(() => 'sidepanel'),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('PageHeader 组件系统', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: PageHeaderProps = {
|
|
||||||
icon: <span data-testid="test-icon">⏰</span>,
|
|
||||||
title: '时间戳转换',
|
|
||||||
subtitle: 'Unix 毫秒数转换与格式化',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PageHeader UI 渲染', () => {
|
|
||||||
it('应渲染页面标题栏&副标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染自定义图标&图标颜色', () => {
|
|
||||||
render(
|
|
||||||
<PageHeader
|
|
||||||
{...defaultProps}
|
|
||||||
icon={<span data-testid="custom-icon">X</span>}
|
|
||||||
iconColor="#FF0000"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应默认使用蓝色作为 primary 色', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
|
||||||
expect(iconContainer).toHaveClass('text-blue-500');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染 badge 组件', () => {
|
|
||||||
const badge = <span data-testid="test-badge">New</span>;
|
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
|
||||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 iconClassName', () => {
|
|
||||||
render(<PageHeader {...defaultProps} iconClassName="custom-icon-class" />);
|
|
||||||
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
|
||||||
expect(iconContainer).toHaveClass('custom-icon-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 titleClassName', () => {
|
|
||||||
render(<PageHeader {...defaultProps} titleClassName="custom-title-class" />);
|
|
||||||
const title = screen.getByText('时间戳转换');
|
|
||||||
expect(title).toHaveClass('custom-title-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 subtitleClassName', () => {
|
|
||||||
render(<PageHeader {...defaultProps} subtitleClassName="custom-subtitle-class" />);
|
|
||||||
const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
|
|
||||||
expect(subtitle).toHaveClass('custom-subtitle-class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应支持自定义 className', () => {
|
|
||||||
const { container } = render(<PageHeader {...defaultProps} className="custom-page-header" />);
|
|
||||||
const outerElement = container.firstChild;
|
|
||||||
expect(outerElement).toHaveClass('custom-page-header');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无副标题时不渲染副标题区域', () => {
|
|
||||||
const { container } = render(<PageHeader icon={defaultProps.icon} title="仅标题" />);
|
|
||||||
const subtitles = container.querySelectorAll('.text-muted-foreground');
|
|
||||||
expect(subtitles.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PageHeader 入口点隐藏', () => {
|
|
||||||
it('popup 模式下应返回 null', async () => {
|
|
||||||
const { getEntryPointType } = await import('@/config/features');
|
|
||||||
vi.mocked(getEntryPointType).mockReturnValue('popup');
|
|
||||||
|
|
||||||
const { container } = render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(container.innerHTML).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import QrCodePreview from '@/components/QrCodePreview';
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
|
|
||||||
|
// 💡 1. 规范对齐:在这个测试文件的头部同样挂载统一的 react-i18next 桩函数,
|
||||||
|
// 与你整个工程的国际化解耦架构完美闭环。
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: vi.fn((ns: string | string[]) => {
|
||||||
|
const nsArray = Array.isArray(ns) ? ns : [ns];
|
||||||
|
return {
|
||||||
|
t: (key: string) => `${nsArray.join(',')}:${key}`,
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('QrCodePreview 组件', () => {
|
describe('QrCodePreview 组件', () => {
|
||||||
const mockOnDownload = vi.fn();
|
const mockOnDownload = vi.fn();
|
||||||
const mockOnCopy = vi.fn();
|
const mockOnCopy = vi.fn();
|
||||||
@@ -18,7 +31,8 @@ describe('QrCodePreview 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
expect(screen.getByText('qrCode:qrCodeWillShow')).toBeInTheDocument();
|
// 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
|
||||||
|
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||||
@@ -43,7 +57,8 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
|
// 💡 修复点 3:切换为正则,无缝过检
|
||||||
|
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||||
@@ -54,13 +69,14 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
|
// 💡 修复点 4:切换为正则,无缝过检
|
||||||
|
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
|
expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
|
expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +89,8 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByText('qrCode:downloadButton'));
|
// 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
|
||||||
|
fireEvent.click(screen.getByText(/downloadButton/));
|
||||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +102,8 @@ describe('QrCodePreview 组件', () => {
|
|||||||
onCopy={mockOnCopy}
|
onCopy={mockOnCopy}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
|
// 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
|
||||||
|
fireEvent.click(screen.getByText(/copyQrButton/));
|
||||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConf
|
|||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
// 💡 1. 核心超进化(WXT 规范):将全局多端 browser 桩进行全量注入与防干涉净化
|
||||||
|
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||||
|
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
|
||||||
|
// 💡 2. 对齐 react-i18next 的分布式国际化桩
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: vi.fn((ns: string | string[]) => {
|
||||||
|
const nsArray = Array.isArray(ns) ? ns : [ns];
|
||||||
|
return {
|
||||||
|
t: (key: string) => `${nsArray.join(',')}:${key}`,
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
ready: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('StorageCleanerConfirm 组件', () => {
|
describe('StorageCleanerConfirm 组件', () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
const mockOnConfirm = vi.fn();
|
const mockOnConfirm = vi.fn();
|
||||||
@@ -36,27 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('open 为 true 时应渲染对话框', () => {
|
it('open 为 true 时应渲染对话框', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText('storageCleaner:confirmTitle')).toBeInTheDocument();
|
// 💡 修复点 3:拥抱模糊正则断言。
|
||||||
|
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
|
||||||
|
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应显示警告信息', () => {
|
it('应显示警告信息', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText(/storageCleaner:irreversible/i)).toBeInTheDocument();
|
expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应将选中的选项显示为标签', () => {
|
it('应将选中的选项显示为标签', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
|
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应显示取消和确认按钮', () => {
|
it('应显示取消和确认按钮', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByRole('button', { name: /common:buttons.cancel/i })).toBeInTheDocument();
|
// 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
|
||||||
expect(
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
screen.getByRole('button', { name: /storageCleaner:confirmAction/i }),
|
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
it('点击取消时应调用 onClose', () => {
|
it('点击取消时应调用 onClose', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /common:buttons.cancel/i }));
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -72,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
it('点击确认时应调用 onConfirm', () => {
|
it('点击确认时应调用 onConfirm', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /storageCleaner:confirmAction/i }));
|
fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
|
||||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -91,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
|
|
||||||
renderComponent({ options: partialOptions });
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.indexedDB')).toBeInTheDocument();
|
expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText('storageCleaner:options.sessionStorage')).not.toBeInTheDocument();
|
expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('storageCleaner:options.cookies')).not.toBeInTheDocument();
|
expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应处理空选项', () => {
|
it('应处理空选项', () => {
|
||||||
@@ -117,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
describe('对话框行为测试', () => {
|
describe('对话框行为测试', () => {
|
||||||
it('open 为 false 时不应渲染', () => {
|
it('open 为 false 时不应渲染', () => {
|
||||||
renderComponent({ open: false });
|
renderComponent({ open: false });
|
||||||
expect(screen.queryByText('storageCleaner:confirmTitle')).not.toBeInTheDocument();
|
expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应使用不同选项渲染', () => {
|
it('应使用不同选项渲染', () => {
|
||||||
@@ -132,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
|
|
||||||
renderComponent({ options: customOptions });
|
renderComponent({ options: customOptions });
|
||||||
|
|
||||||
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
|
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 10 features defined', () => {
|
it('should have 11 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(10);
|
expect(FEATURES).toHaveLength(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
@@ -95,7 +95,7 @@ describe('features', () => {
|
|||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(10);
|
expect(allKeys).toHaveLength(11);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
@@ -106,6 +106,7 @@ describe('features', () => {
|
|||||||
expect(allKeys).toContain('base64Converter');
|
expect(allKeys).toContain('base64Converter');
|
||||||
expect(allKeys).toContain('markdownToHtml');
|
expect(allKeys).toContain('markdownToHtml');
|
||||||
expect(allKeys).toContain('htmlToMarkdown');
|
expect(allKeys).toContain('htmlToMarkdown');
|
||||||
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,9 +123,9 @@ describe('features', () => {
|
|||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 9 items in page order', () => {
|
it('should have 10 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(9);
|
expect(pageOrder).toHaveLength(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Code,
|
Code,
|
||||||
File,
|
File,
|
||||||
|
MousePointerClick,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
@@ -26,6 +27,7 @@ const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
|||||||
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||||
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
||||||
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
||||||
|
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||||
|
|
||||||
export interface FeatureConfig {
|
export interface FeatureConfig {
|
||||||
key: PageType;
|
key: PageType;
|
||||||
@@ -170,6 +172,19 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
tab: HtmlToMarkdownPage,
|
tab: HtmlToMarkdownPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'rightClickRestorer',
|
||||||
|
labelKey: 'features:rightClickRestorer.title',
|
||||||
|
descriptionKey: 'features:rightClickRestorer.description',
|
||||||
|
themeColorKey: 'success',
|
||||||
|
icon: MousePointerClick,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: RightClickRestorerPage,
|
||||||
|
sidepanel: RightClickRestorerPage,
|
||||||
|
tab: RightClickRestorerPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
|
||||||
|
|
||||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
|
||||||
|
|
||||||
export type UnitType = 'ms' | 's';
|
|
||||||
export type ZoneType = (typeof ZONES)[number];
|
|
||||||
|
|
||||||
export const THEME_COLORS = {
|
|
||||||
primary: '#1976d2',
|
|
||||||
primaryDark: '#1565c0',
|
|
||||||
primaryLight: '#42a5f5',
|
|
||||||
success: '#2e7d32',
|
|
||||||
successDark: '#1b5e20',
|
|
||||||
successLight: '#4caf50',
|
|
||||||
warning: '#e65100',
|
|
||||||
warningDark: '#bf360c',
|
|
||||||
warningLight: '#ff9800',
|
|
||||||
error: '#c62828',
|
|
||||||
errorDark: '#b71c1c',
|
|
||||||
errorLight: '#f44336',
|
|
||||||
purple: '#6a1b9a',
|
|
||||||
purpleDark: '#4a148c',
|
|
||||||
purpleLight: '#9c27b0',
|
|
||||||
indigo: '#303f9f',
|
|
||||||
indigoDark: '#1a237e',
|
|
||||||
indigoLight: '#7986cb',
|
|
||||||
white: '#FFFFFF',
|
|
||||||
black: '#000000',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const STATUS_COLORS = {
|
|
||||||
success: THEME_COLORS.success,
|
|
||||||
warning: THEME_COLORS.warning,
|
|
||||||
error: THEME_COLORS.error,
|
|
||||||
info: THEME_COLORS.primary,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const timestampPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storageCleanerPageStyles = {
|
|
||||||
warningColor: THEME_COLORS.warning,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const qrCodePageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.success,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const textStatisticsPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.purple,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const base64ConverterPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.indigo,
|
|
||||||
};
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档
|
|
||||||
|
|
||||||
- 日期:2026-05-13
|
|
||||||
- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离
|
|
||||||
|
|
||||||
## 背景与目标
|
|
||||||
|
|
||||||
当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。
|
|
||||||
|
|
||||||
问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。
|
|
||||||
|
|
||||||
目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。
|
|
||||||
|
|
||||||
## 设计概要
|
|
||||||
|
|
||||||
新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer` 的 `Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。
|
|
||||||
|
|
||||||
## 组件设计
|
|
||||||
|
|
||||||
### `components/PageErrorBoundary.tsx`(新增)
|
|
||||||
|
|
||||||
复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异:
|
|
||||||
|
|
||||||
- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构:
|
|
||||||
- 图标 (`ErrorOutlineIcon`)
|
|
||||||
- 标题:"该页面加载失败"
|
|
||||||
- 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。"
|
|
||||||
- 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动)
|
|
||||||
- 主操作按钮:"重试"(`RefreshIcon`)—— 重置内部 state,让子树重新挂载
|
|
||||||
- **`resetKey` prop**:可选;当 `resetKey` 在 `componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作
|
|
||||||
- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报
|
|
||||||
|
|
||||||
接口:
|
|
||||||
```ts
|
|
||||||
interface PageErrorBoundaryProps {
|
|
||||||
children: ReactNode;
|
|
||||||
resetKey?: string | number; // 变化时自动重置
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `components/ErrorBoundary.tsx`(不变)
|
|
||||||
|
|
||||||
保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。
|
|
||||||
|
|
||||||
## 集成点
|
|
||||||
|
|
||||||
### `components/RouterContainer.tsx`(修改)
|
|
||||||
|
|
||||||
在 `Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Suspense fallback={<Spinner />}>
|
|
||||||
<PageErrorBoundary resetKey={currentPage}>
|
|
||||||
{Component && <Component />}
|
|
||||||
</PageErrorBoundary>
|
|
||||||
</Suspense>
|
|
||||||
```
|
|
||||||
|
|
||||||
说明:
|
|
||||||
- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误
|
|
||||||
- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预
|
|
||||||
- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义
|
|
||||||
|
|
||||||
### `entrypoints/options/App.tsx`(修改)
|
|
||||||
|
|
||||||
将顶层 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`。options 是单页应用,统一使用页面级错误卡片即可。
|
|
||||||
|
|
||||||
### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变)
|
|
||||||
|
|
||||||
保留外层 `<ErrorBoundary>` 包裹 `<RouterContainer />`,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职:
|
|
||||||
|
|
||||||
- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域
|
|
||||||
- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底
|
|
||||||
|
|
||||||
## 数据流 / 错误处理
|
|
||||||
|
|
||||||
### 捕获路径
|
|
||||||
- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获
|
|
||||||
- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获
|
|
||||||
- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理)
|
|
||||||
|
|
||||||
### 恢复路径
|
|
||||||
- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染
|
|
||||||
- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`)
|
|
||||||
- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具
|
|
||||||
|
|
||||||
### 日志
|
|
||||||
沿用 `console.error`;不引入 Sentry / 外部上报。
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
|
|
||||||
### 新增 `components/__tests__/PageErrorBoundary.test.tsx`
|
|
||||||
|
|
||||||
1. 正常渲染:子组件正常渲染时,输出原始 children
|
|
||||||
2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息)
|
|
||||||
3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容
|
|
||||||
4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children
|
|
||||||
5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置)
|
|
||||||
6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见
|
|
||||||
|
|
||||||
### 新增 `components/__tests__/RouterContainer.test.tsx`
|
|
||||||
|
|
||||||
- 通过 mock `useRouter` 与 `FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在
|
|
||||||
- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染
|
|
||||||
|
|
||||||
### 不变
|
|
||||||
- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变)
|
|
||||||
|
|
||||||
### 风格
|
|
||||||
遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react` 的 `render` + `screen.getByText` 断言。
|
|
||||||
|
|
||||||
## 文件清单
|
|
||||||
|
|
||||||
**新增:**
|
|
||||||
- `components/PageErrorBoundary.tsx`
|
|
||||||
- `components/__tests__/PageErrorBoundary.test.tsx`
|
|
||||||
- `components/__tests__/RouterContainer.test.tsx`
|
|
||||||
|
|
||||||
**修改:**
|
|
||||||
- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}`
|
|
||||||
- `entrypoints/options/App.tsx` — 将 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`
|
|
||||||
|
|
||||||
**不变:**
|
|
||||||
- `components/ErrorBoundary.tsx`
|
|
||||||
- `components/__tests__/ErrorBoundary.test.tsx`
|
|
||||||
- `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`
|
|
||||||
- `config/features.tsx`
|
|
||||||
|
|
||||||
## 验收标准
|
|
||||||
|
|
||||||
1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击
|
|
||||||
2. 在错误状态下切换到其他工具,新页面能正常加载与显示
|
|
||||||
3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载
|
|
||||||
4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误
|
|
||||||
5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile`、`npm run lint`、`npm run test` 均通过
|
|
||||||
+112
-25
@@ -5,12 +5,12 @@ import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMen
|
|||||||
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
// 1. 扩展初次安装或更新时,注册右键上下文大闸
|
// 1. 扩展初次安装或更新时,注册右键上下文菜单
|
||||||
browser.runtime.onInstalled.addListener(() => {
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
createAllContextMenus();
|
createAllContextMenus();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. 右键点击中央中枢路由
|
// 2. 右键点击路由
|
||||||
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
||||||
const result = parseContextMenuClick(info.menuItemId as string, info);
|
const result = parseContextMenuClick(info.menuItemId as string, info);
|
||||||
|
|
||||||
@@ -24,12 +24,10 @@ export default defineBackground(() => {
|
|||||||
const { featureKey, payload } = result.data;
|
const { featureKey, payload } = result.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查侧边栏(Side Panel)的挂载激活状态
|
|
||||||
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||||
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||||
|
|
||||||
if (isSidePanelOpen) {
|
if (isSidePanelOpen) {
|
||||||
// 如果侧边栏正开着,利用高性能管道直发
|
|
||||||
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -37,18 +35,13 @@ export default defineBackground(() => {
|
|||||||
console.debug('[Context Menu] Side panel pipeline is not available:', err);
|
console.debug('[Context Menu] Side panel pipeline is not available:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取
|
|
||||||
await saveContextMenuData({ featureKey, payload });
|
await saveContextMenuData({ featureKey, payload });
|
||||||
|
|
||||||
// 打开 popup 弹窗
|
|
||||||
try {
|
try {
|
||||||
await browser.action.openPopup();
|
await browser.action.openPopup();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁!
|
|
||||||
// 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标,
|
|
||||||
// 数据依旧完好如初,完美契合了你的设计注释!
|
|
||||||
console.warn(
|
console.warn(
|
||||||
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留在内存中:',
|
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,7 +58,96 @@ export default defineBackground(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 💡 3. 异步刷新请求监听
|
// 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
|
||||||
|
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
|
||||||
|
const sender = message.sender as chrome.runtime.MessageSender | undefined;
|
||||||
|
const tabId = sender?.tab?.id;
|
||||||
|
|
||||||
|
if (!tabId) {
|
||||||
|
console.warn('[RightClickRestorer] Injection request missing tabId');
|
||||||
|
return { success: false, message: 'Missing tabId' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => {
|
||||||
|
'use strict';
|
||||||
|
const w = window as unknown as Record<string, unknown>;
|
||||||
|
if (w.__testingToolsRightClickPatched) return;
|
||||||
|
w.__testingToolsRightClickPatched = true;
|
||||||
|
|
||||||
|
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
|
||||||
|
|
||||||
|
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
|
||||||
|
const _origPreventDefault = MouseEvent.prototype.preventDefault;
|
||||||
|
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
|
||||||
|
value: function (this: MouseEvent) {
|
||||||
|
const t = this.type;
|
||||||
|
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return _origPreventDefault.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
|
||||||
|
const _origStopPropagation = Event.prototype.stopPropagation;
|
||||||
|
Object.defineProperty(Event.prototype, 'stopPropagation', {
|
||||||
|
value: function (this: Event) {
|
||||||
|
if (PROTECTED.includes(this.type)) return;
|
||||||
|
return _origStopPropagation.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
|
||||||
|
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
|
||||||
|
value: function (this: Event) {
|
||||||
|
if (PROTECTED.includes(this.type)) return;
|
||||||
|
return _origStopImmediatePropagation.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
|
||||||
|
let _docOnContextMenu: unknown = null;
|
||||||
|
Object.defineProperty(document, 'oncontextmenu', {
|
||||||
|
get() {
|
||||||
|
return _docOnContextMenu;
|
||||||
|
},
|
||||||
|
set(fn: unknown) {
|
||||||
|
if (typeof fn === 'function') {
|
||||||
|
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
|
||||||
|
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(
|
||||||
|
this,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return r === false ? true : r;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
_docOnContextMenu = fn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
world: 'MAIN',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[RightClickRestorer] executeScript failed:', errorMsg);
|
||||||
|
return { success: false, message: errorMsg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
|
||||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||||
const { tabId, delay = 0 } = message.data;
|
const { tabId, delay = 0 } = message.data;
|
||||||
|
|
||||||
@@ -75,28 +157,33 @@ export default defineBackground(() => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御
|
if (delay <= 0) {
|
||||||
// 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动
|
executeReload();
|
||||||
if (delay > 0 && delay < 1000) {
|
return { success: true };
|
||||||
setTimeout(executeReload, delay);
|
}
|
||||||
} else if (delay >= 1000) {
|
|
||||||
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
|
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
|
||||||
|
|
||||||
// 创建一个临时的一次性 Alarm 闹钟
|
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
|
||||||
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
||||||
|
|
||||||
// 动态注册一个一次性的生命周期续航守卫
|
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
|
||||||
|
const cleanupTimeout = setTimeout(() => {
|
||||||
|
browser.alarms.onAlarm.removeListener(alarmListener);
|
||||||
|
browser.alarms.clear(alarmName).catch(() => {});
|
||||||
|
}, delay + 5000);
|
||||||
|
|
||||||
const alarmListener = (alarm: { name: string }) => {
|
const alarmListener = (alarm: { name: string }) => {
|
||||||
if (alarm.name === alarmName) {
|
if (alarm.name !== alarmName) return;
|
||||||
|
|
||||||
|
clearTimeout(cleanupTimeout);
|
||||||
executeReload();
|
executeReload();
|
||||||
browser.alarms.onAlarm.removeListener(alarmListener);
|
browser.alarms.onAlarm.removeListener(alarmListener);
|
||||||
}
|
browser.alarms.clear(alarmName).catch(() => {});
|
||||||
};
|
};
|
||||||
browser.alarms.onAlarm.addListener(alarmListener);
|
|
||||||
} else {
|
|
||||||
executeReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' };
|
browser.alarms.onAlarm.addListener(alarmListener);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { MessageAction, onMessage } from '@/utils/messages';
|
|||||||
import { getTextStats } from '@/utils/textStatistics';
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||||
|
|
||||||
// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文
|
|
||||||
function getI18nText(key: string, fallback: string): string {
|
function getI18nText(key: string, fallback: string): string {
|
||||||
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
||||||
return chrome.i18n.getMessage(key) || fallback;
|
return chrome.i18n.getMessage(key) || fallback;
|
||||||
@@ -39,20 +38,16 @@ function convertTimestamp(input: string): string {
|
|||||||
let lastClickX = 0;
|
let lastClickX = 0;
|
||||||
let lastClickY = 0;
|
let lastClickY = 0;
|
||||||
|
|
||||||
// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标
|
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'contextmenu',
|
'contextmenu',
|
||||||
(e) => {
|
(e) => {
|
||||||
lastClickX = e.clientX;
|
lastClickX = e.clientX;
|
||||||
lastClickY = e.clientY;
|
lastClickY = e.clientY;
|
||||||
},
|
},
|
||||||
{ capture: true, passive: true }, // 优化滚动与捕获性能
|
{ capture: true, passive: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
export function initContextMenuHandler(): void {
|
export function initContextMenuHandler(): void {
|
||||||
// 💡 2. 全局自净化大闸(Global Auto-Purge Grid):
|
|
||||||
// 当用户在网页上进行左键点击、滚动视视口、或调整大小时,
|
|
||||||
// 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣!
|
|
||||||
const dismissPopover = (): void => {
|
const dismissPopover = (): void => {
|
||||||
hidePopover();
|
hidePopover();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { GripVertical, RefreshCw, Settings } from 'lucide-react';
|
import { GripVertical, RefreshCw } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
closestCenter,
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
} from '@/config/features';
|
} from '@/config/features';
|
||||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||||
@@ -317,12 +316,6 @@ export default function App() {
|
|||||||
{/* 顶部标题与导航栏 */}
|
{/* 顶部标题与导航栏 */}
|
||||||
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
|
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<PageHeader
|
|
||||||
icon={<Settings size={20} />}
|
|
||||||
iconColor="#1976d2"
|
|
||||||
title="应用设置"
|
|
||||||
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
|
|
||||||
/>
|
|
||||||
{/* Tab 与恢复按钮同行 */}
|
{/* Tab 与恢复按钮同行 */}
|
||||||
<div className="flex items-end justify-between border-b-0">
|
<div className="flex items-end justify-between border-b-0">
|
||||||
<div className="flex-1 flex">
|
<div className="flex-1 flex">
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
|
const BADGE_ID = 'testing-tools-right-click-restorer-badge';
|
||||||
|
const BADGE_STYLE_ID = 'testing-tools-right-click-restorer-badge-style';
|
||||||
|
|
||||||
|
let isRestored = false;
|
||||||
|
|
||||||
|
function updateBadge(): void {
|
||||||
|
const badge = document.getElementById(BADGE_ID);
|
||||||
|
if (badge) {
|
||||||
|
badge.style.opacity = isRestored ? '1' : '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBadge(): void {
|
||||||
|
if (document.getElementById(BADGE_ID)) return;
|
||||||
|
|
||||||
|
if (!document.getElementById(BADGE_STYLE_ID)) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = BADGE_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#${BADGE_ID} {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 2147483646;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #2e7d32;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#${BADGE_ID} {
|
||||||
|
background: #4caf50;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.id = BADGE_ID;
|
||||||
|
badge.textContent = '\u53f3\u952e\u5df2\u89e3\u9501';
|
||||||
|
document.body.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Main World 注入 ======================== */
|
||||||
|
|
||||||
|
async function injectMainWorldScript(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT);
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[RightClickRestorer] Background injection failed:', response.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RightClickRestorer] Failed to request main world injection:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Isolated World 事件拦截 ======================== */
|
||||||
|
|
||||||
|
function installEventIntercepts(): void {
|
||||||
|
window.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
(e) => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 部分网站在 mousedown 阶段阻止 contextmenu
|
||||||
|
window.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
(e) => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
if (e.button === 2 || (e.buttons & 2) !== 0) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 遮罩层穿透 ======================== */
|
||||||
|
|
||||||
|
const MEDIA_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'SVG']);
|
||||||
|
|
||||||
|
function initMousePenetration(): void {
|
||||||
|
window.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
(e) => {
|
||||||
|
if (!isRestored || e.button !== 2) return;
|
||||||
|
|
||||||
|
const elements = document.elementsFromPoint(e.clientX, e.clientY);
|
||||||
|
if (!elements.length) return;
|
||||||
|
|
||||||
|
let targetMedia: HTMLElement | null = null;
|
||||||
|
for (const el of elements) {
|
||||||
|
if (MEDIA_TAGS.has(el.tagName)) {
|
||||||
|
targetMedia = el as HTMLElement;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetMedia) return;
|
||||||
|
|
||||||
|
const modified: Array<{ el: HTMLElement; original: string | null }> = [];
|
||||||
|
let foundMedia = false;
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
const htmlEl = el as HTMLElement;
|
||||||
|
|
||||||
|
if (el === targetMedia) {
|
||||||
|
foundMedia = true;
|
||||||
|
const original = htmlEl.style.pointerEvents || null;
|
||||||
|
htmlEl.style.setProperty('pointer-events', 'all', 'important');
|
||||||
|
modified.push({ el: htmlEl, original });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundMedia) {
|
||||||
|
const original = htmlEl.style.pointerEvents || null;
|
||||||
|
htmlEl.style.setProperty('pointer-events', 'none', 'important');
|
||||||
|
modified.push({ el: htmlEl, original });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const { el, original } of modified) {
|
||||||
|
if (original === null || original === '') {
|
||||||
|
el.style.removeProperty('pointer-events');
|
||||||
|
} else {
|
||||||
|
el.style.pointerEvents = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 激活保护 ======================== */
|
||||||
|
|
||||||
|
async function activateProtection(): Promise<void> {
|
||||||
|
if (isRestored) return;
|
||||||
|
|
||||||
|
await injectMainWorldScript();
|
||||||
|
|
||||||
|
// 再次检查,防止并发调用导致重复注册
|
||||||
|
if (isRestored) return;
|
||||||
|
|
||||||
|
installEventIntercepts();
|
||||||
|
initMousePenetration();
|
||||||
|
|
||||||
|
isRestored = true;
|
||||||
|
updateBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 消息通信 ======================== */
|
||||||
|
|
||||||
|
function initMessaging(): void {
|
||||||
|
onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => {
|
||||||
|
await activateProtection();
|
||||||
|
return { success: true, restored: isRestored };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => {
|
||||||
|
return { success: true, restored: isRestored };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 入口 ======================== */
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['<all_urls>'],
|
||||||
|
runAt: 'document_start',
|
||||||
|
main() {
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', createBadge, { once: true });
|
||||||
|
} else {
|
||||||
|
createBadge();
|
||||||
|
}
|
||||||
|
initMessaging();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -60,6 +60,12 @@ export default tseslint.config(
|
|||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
|
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
|
||||||
rules: {
|
rules: {
|
||||||
// 激活 react-hooks 官方推荐规则
|
// 激活 react-hooks 官方推荐规则
|
||||||
@@ -68,6 +74,8 @@ export default tseslint.config(
|
|||||||
...reactPlugin.configs.recommended.rules,
|
...reactPlugin.configs.recommended.rules,
|
||||||
...reactPlugin.configs['jsx-runtime'].rules,
|
...reactPlugin.configs['jsx-runtime'].rules,
|
||||||
|
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
|
||||||
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
|
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
|||||||
@@ -37,5 +37,9 @@
|
|||||||
"htmlToMarkdown": {
|
"htmlToMarkdown": {
|
||||||
"title": "HTML to Markdown",
|
"title": "HTML to Markdown",
|
||||||
"description": "Real-time HTML conversion and Markdown preview"
|
"description": "Real-time HTML conversion and Markdown preview"
|
||||||
|
},
|
||||||
|
"rightClickRestorer": {
|
||||||
|
"title": "Right Click Restorer",
|
||||||
|
"description": "Detect and restore disabled browser right-click menus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"title": "Right Click Restorer",
|
||||||
|
"description": "Detect and restore disabled browser right-click menus",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"currentDomain": "Current Domain",
|
||||||
|
"statusLocked": "Locked",
|
||||||
|
"statusUnlocked": "Unlocked",
|
||||||
|
"unsupported": "Unsupported",
|
||||||
|
"unsupportedDesc": "The current page is a browser internal or extension page. Right-click unlock is not available. Please switch to a regular webpage.",
|
||||||
|
"unlockDesc": "Click the button below to temporarily unlock the right-click menu for the current website. You will need to unlock again after refreshing the page.",
|
||||||
|
"unlockBtn": "Unlock Right Click",
|
||||||
|
"alreadyUnlocked": "Right Click Unlocked"
|
||||||
|
}
|
||||||
@@ -37,5 +37,9 @@
|
|||||||
"htmlToMarkdown": {
|
"htmlToMarkdown": {
|
||||||
"title": "HTML 转 Markdown",
|
"title": "HTML 转 Markdown",
|
||||||
"description": "实时 HTML 转换与 Markdown 预览"
|
"description": "实时 HTML 转换与 Markdown 预览"
|
||||||
|
},
|
||||||
|
"rightClickRestorer": {
|
||||||
|
"title": "右键恢复",
|
||||||
|
"description": "检测并恢复被网站禁用的浏览器右键菜单"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"title": "右键恢复",
|
||||||
|
"description": "检测并恢复被网站禁用的浏览器右键菜单",
|
||||||
|
"loading": "正在加载...",
|
||||||
|
"currentDomain": "当前域名",
|
||||||
|
"statusLocked": "未解锁",
|
||||||
|
"statusUnlocked": "已解锁",
|
||||||
|
"unsupported": "不支持",
|
||||||
|
"unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
|
||||||
|
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
|
||||||
|
"unlockBtn": "解锁当前网站右键",
|
||||||
|
"alreadyUnlocked": "右键已解锁"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -19,7 +19,7 @@ interface Base64ConverterSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
|
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
|
||||||
const { t } = useTranslation('base64Converter');
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
|
|
||||||
const [direction, setDirection] = useStorageState(
|
const [direction, setDirection] = useStorageState(
|
||||||
`base64Converter/${mode}Mode/direction`,
|
`base64Converter/${mode}Mode/direction`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Trash2, Upload } from 'lucide-react';
|
import { Trash2, Upload } from 'lucide-react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,7 +15,7 @@ const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
|||||||
val === 'encode' || val === 'decode';
|
val === 'encode' || val === 'decode';
|
||||||
|
|
||||||
export default function FileMode() {
|
export default function FileMode() {
|
||||||
const { t } = useTranslation('base64Converter');
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
const [direction, setDirection] = useStorageState(
|
const [direction, setDirection] = useStorageState(
|
||||||
'base64Converter/fileMode/direction',
|
'base64Converter/fileMode/direction',
|
||||||
'encode',
|
'encode',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Image as ImageIcon, Trash2 } from 'lucide-react';
|
import { Image as ImageIcon, Trash2 } from 'lucide-react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -15,7 +15,7 @@ const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
|||||||
val === 'encode' || val === 'decode';
|
val === 'encode' || val === 'decode';
|
||||||
|
|
||||||
export default function ImageMode() {
|
export default function ImageMode() {
|
||||||
const { t } = useTranslation('base64Converter');
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
const [direction, setDirection] = useStorageState(
|
const [direction, setDirection] = useStorageState(
|
||||||
'base64Converter/imageMode/direction',
|
'base64Converter/imageMode/direction',
|
||||||
'encode',
|
'encode',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
|
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
@@ -20,7 +20,7 @@ interface TextModeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||||
const { t } = useTranslation('base64Converter');
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
|
|
||||||
// 1. 纯净的核心源状态机:只保留输入源和转换方向
|
// 1. 纯净的核心源状态机:只保留输入源和转换方向
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ describe('FileMode', () => {
|
|||||||
it('应该渲染文件上传区域', async () => {
|
it('应该渲染文件上传区域', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
|
||||||
expect(screen.getByText('maxFileSize')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:maxFileSize')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该处理有效的文件选择', async () => {
|
it('应该处理有效的文件选择', async () => {
|
||||||
@@ -44,7 +44,7 @@ describe('FileMode', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||||
expect(screen.getByText('base64Output')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ describe('FileMode', () => {
|
|||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
|
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,11 +76,11 @@ describe('FileMode', () => {
|
|||||||
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
expect(screen.getByText('test.txt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('clear'));
|
fireEvent.click(screen.getByText('base64Converter:clear'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
|
expect(screen.queryByText('test.txt')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('clickOrDropToFile')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:clickOrDropToFile')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,8 +108,8 @@ describe('FileMode', () => {
|
|||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/originalSize/)).toBeInTheDocument();
|
expect(screen.getByText(/base64Converter:originalSize/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/encodedSize/)).toBeInTheDocument();
|
expect(screen.getByText(/base64Converter:encodedSize/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,23 +129,25 @@ describe('FileMode', () => {
|
|||||||
|
|
||||||
it('应该渲染 encode/decode 切换按钮', () => {
|
it('应该渲染 encode/decode 切换按钮', () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
expect(screen.getByText('encode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
|
||||||
expect(screen.getByText('decode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('切到 decode 应该显示 Base64 输入框', async () => {
|
it('切到 decode 应该显示 Base64 输入框', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
expect(await screen.findByPlaceholderText('decodeBase64Placeholder')).toBeInTheDocument();
|
expect(
|
||||||
|
await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder'),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
|
it('解码 PDF Base64 后应该显示 application/pdf 与默认文件名 decoded.pdf', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -153,7 +155,7 @@ describe('FileMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
|
expect(screen.getByText(/application\/pdf/)).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
|
expect(screen.getByDisplayValue('decoded.pdf')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -162,9 +164,9 @@ describe('FileMode', () => {
|
|||||||
it('解码后的文件名应该可编辑', async () => {
|
it('解码后的文件名应该可编辑', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -179,24 +181,24 @@ describe('FileMode', () => {
|
|||||||
it('解码后应该显示下载按钮', async () => {
|
it('解码后应该显示下载按钮', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(250);
|
vi.advanceTimersByTime(250);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText('download')).toBeInTheDocument();
|
expect(await screen.findByText('base64Converter:download')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -204,16 +206,16 @@ describe('FileMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('切换方向时应该清空解码状态', async () => {
|
it('切换方向时应该清空解码状态', async () => {
|
||||||
render(<FileMode />);
|
render(<FileMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
fireEvent.change(input, { target: { value: 'JVBERi0K' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -221,10 +223,10 @@ describe('FileMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:decodedFileOutput')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('encode'));
|
fireEvent.click(screen.getByText('base64Converter:encode'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
|
expect(screen.queryByText('decodedFileOutput')).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ describe('ImageMode', () => {
|
|||||||
it('应该渲染图像上传区域', async () => {
|
it('应该渲染图像上传区域', async () => {
|
||||||
render(<ImageMode />);
|
render(<ImageMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
|
||||||
expect(screen.getByText('supportedFormats')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:supportedFormats')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该接受有效的图像文件', async () => {
|
it('应该接受有效的图像文件', async () => {
|
||||||
@@ -40,7 +40,7 @@ describe('ImageMode', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||||
expect(screen.getByText('base64Output')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ describe('ImageMode', () => {
|
|||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('unsupportedImageType');
|
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:unsupportedImageType');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ describe('ImageMode', () => {
|
|||||||
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
fireEvent.change(hiddenInput, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('alert')).toHaveTextContent('fileSizeExceeded');
|
expect(screen.getByRole('alert')).toHaveTextContent('base64Converter:fileSizeExceeded');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,11 +98,11 @@ describe('ImageMode', () => {
|
|||||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('clear'));
|
fireEvent.click(screen.getByText('base64Converter:clear'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
|
expect(screen.queryByText('test.png')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('clickOrDropToImage')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:clickOrDropToImage')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,16 +124,16 @@ describe('ImageMode', () => {
|
|||||||
it('应该渲染 encode/decode 切换按钮', async () => {
|
it('应该渲染 encode/decode 切换按钮', async () => {
|
||||||
render(<ImageMode />);
|
render(<ImageMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
expect(screen.getByText('encode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
|
||||||
expect(screen.getByText('decode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('解码 PNG Base64 后应该显示图像预览', async () => {
|
it('解码 PNG Base64 后应该显示图像预览', async () => {
|
||||||
render(<ImageMode />);
|
render(<ImageMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -141,7 +141,7 @@ describe('ImageMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('decodedImageOutput')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:decodedImageOutput')).toBeInTheDocument();
|
||||||
const img = screen.getByAltText('decoded preview');
|
const img = screen.getByAltText('decoded preview');
|
||||||
expect(img).toBeInTheDocument();
|
expect(img).toBeInTheDocument();
|
||||||
expect(img.tagName.toLowerCase()).toBe('img');
|
expect(img.tagName.toLowerCase()).toBe('img');
|
||||||
@@ -151,9 +151,9 @@ describe('ImageMode', () => {
|
|||||||
it('解码后默认文件名应该为 decoded.png', async () => {
|
it('解码后默认文件名应该为 decoded.png', async () => {
|
||||||
render(<ImageMode />);
|
render(<ImageMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -166,9 +166,9 @@ describe('ImageMode', () => {
|
|||||||
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
it('解码非法 Base64 应该显示 invalidBase64 错误', async () => {
|
||||||
render(<ImageMode />);
|
render(<ImageMode />);
|
||||||
await waitForStorageReady();
|
await waitForStorageReady();
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = await screen.findByPlaceholderText('decodeBase64Placeholder');
|
const input = await screen.findByPlaceholderText('base64Converter:decodeBase64Placeholder');
|
||||||
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
fireEvent.change(input, { target: { value: '!!!not base64' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -176,7 +176,7 @@ describe('ImageMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,18 +18,18 @@ describe('TextMode', () => {
|
|||||||
|
|
||||||
it('应该渲染编码/解码切换按钮', () => {
|
it('应该渲染编码/解码切换按钮', () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
expect(screen.getByText('encode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:encode')).toBeInTheDocument();
|
||||||
expect(screen.getByText('decode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:decode')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染输入框和转换按钮', () => {
|
it('应该渲染输入框和转换按钮', () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
expect(screen.getByPlaceholderText('textInputPlaceholder')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText('base64Converter:textInputPlaceholder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该将文本编码为 Base64', async () => {
|
it('应该将文本编码为 Base64', async () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
const input = screen.getByPlaceholderText('textInputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -37,7 +37,7 @@ describe('TextMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('base64Output')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:base64Output')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
|
expect(screen.getByTestId('copy-button')).toHaveTextContent('SGVsbG8=');
|
||||||
});
|
});
|
||||||
@@ -46,9 +46,9 @@ describe('TextMode', () => {
|
|||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
// 切换到解码模式
|
// 切换到解码模式
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('base64InputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
|
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -56,7 +56,7 @@ describe('TextMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('textOutput')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:textOutput')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello');
|
expect(screen.getByTestId('copy-button')).toHaveTextContent('Hello');
|
||||||
});
|
});
|
||||||
@@ -65,9 +65,9 @@ describe('TextMode', () => {
|
|||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
// 切换到解码模式
|
// 切换到解码模式
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('base64InputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'invalid!!!' } });
|
fireEvent.change(input, { target: { value: 'invalid!!!' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -75,7 +75,7 @@ describe('TextMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('invalidBase64')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:invalidBase64')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ describe('TextMode', () => {
|
|||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
// 先编码
|
// 先编码
|
||||||
const input = screen.getByPlaceholderText('textInputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -95,7 +95,7 @@ describe('TextMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 切换方向
|
// 切换方向
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
// 输出应该被清除
|
// 输出应该被清除
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -106,7 +106,7 @@ describe('TextMode', () => {
|
|||||||
it('点击清除按钮应该清空所有内容', async () => {
|
it('点击清除按钮应该清空所有内容', async () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('textInputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:textInputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -128,48 +128,48 @@ describe('TextMode', () => {
|
|||||||
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
|
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('base64InputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
|
||||||
fireEvent.change(input, {
|
fireEvent.change(input, {
|
||||||
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
|
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('imageDataUriHint')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:imageDataUriHint')).toBeInTheDocument();
|
||||||
expect(screen.getByText('switchToImageMode')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:switchToImageMode')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
|
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('base64InputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
|
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
|
||||||
|
|
||||||
expect(screen.queryByText('imageDataUriHint')).not.toBeInTheDocument();
|
expect(screen.queryByText('base64Converter:imageDataUriHint')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
|
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
|
||||||
const onSwitch = vi.fn();
|
const onSwitch = vi.fn();
|
||||||
render(<TextMode onSwitchToImageMode={onSwitch} />);
|
render(<TextMode onSwitchToImageMode={onSwitch} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
const input = screen.getByPlaceholderText('base64InputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
|
||||||
fireEvent.change(input, {
|
fireEvent.change(input, {
|
||||||
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
|
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('switchToImageMode'));
|
fireEvent.click(screen.getByText('base64Converter:switchToImageMode'));
|
||||||
expect(onSwitch).toHaveBeenCalledTimes(1);
|
expect(onSwitch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
|
it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
|
||||||
render(<TextMode />);
|
render(<TextMode />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('decode'));
|
fireEvent.click(screen.getByText('base64Converter:decode'));
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('base64InputPlaceholder');
|
const input = screen.getByPlaceholderText('base64Converter:base64InputPlaceholder');
|
||||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -177,7 +177,7 @@ describe('TextMode', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('binaryDataDetected')).toBeInTheDocument();
|
expect(screen.getByText('base64Converter:binaryDataDetected')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,11 +73,4 @@ describe('Base64ConverterPage', () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染页面标题', async () => {
|
|
||||||
render(<Base64ConverterPage />);
|
|
||||||
await waitForStorageInit();
|
|
||||||
expect(screen.getByText('base64Converter:pageTitle')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('base64Converter:pageSubtitle')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { Image as ImageIcon, Type, Upload } from 'lucide-react';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { base64ConverterPageStyles } from '@/config/pageTheme';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
import { useStorageState } from '@/utils/useStorageState';
|
||||||
import type { Base64ConverterPageMode } from '@/types/storage';
|
import type { Base64ConverterPageMode } from '@/types/storage';
|
||||||
import TextMode from './TextMode';
|
import TextMode from './TextMode';
|
||||||
@@ -22,22 +19,8 @@ export default function Index() {
|
|||||||
isValidPageMode,
|
isValidPageMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
const modeIcon: Record<PageMode, React.ReactNode> = {
|
|
||||||
text: <Type className="h-4 w-4" />,
|
|
||||||
file: <Upload className="h-4 w-4" />,
|
|
||||||
image: <ImageIcon className="h-4 w-4" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none animate-in fade-in duration-300">
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none animate-in fade-in duration-300">
|
||||||
<PageHeader
|
|
||||||
title={t('base64Converter:pageTitle')}
|
|
||||||
subtitle={t('base64Converter:pageSubtitle')}
|
|
||||||
icon={modeIcon[pageMode]}
|
|
||||||
iconColor={base64ConverterPageStyles.primaryColor}
|
|
||||||
className="pb-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={pageMode}
|
value={pageMode}
|
||||||
options={[
|
options={[
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import type { FileToBase64Result } from '@/utils/base64Converter';
|
import type { FileToBase64Result } from '@/utils/base64Converter';
|
||||||
import {
|
import {
|
||||||
base64ToBlob,
|
base64ToBlob,
|
||||||
@@ -21,7 +21,7 @@ interface UseBase64ConverterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBase64Converter({ mode }: UseBase64ConverterProps) {
|
export function useBase64Converter({ mode }: UseBase64ConverterProps) {
|
||||||
const { t } = useTranslation('base64Converter');
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
|
|
||||||
const [result, setResult] = useState<FileToBase64Result | null>(null);
|
const [result, setResult] = useState<FileToBase64Result | null>(null);
|
||||||
const [info, setInfo] = useState<FileInfo | null>(null);
|
const [info, setInfo] = useState<FileInfo | null>(null);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { Code, Download, Trash2 } from 'lucide-react';
|
import { Download, Trash2 } from 'lucide-react';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button
|
import { Button } from '@/components/ui/button'; // 💡 1. 全面回归规范:引入原生的 shadcn 原子 Button
|
||||||
@@ -51,12 +50,6 @@ export default function HtmlToMarkdownPage() {
|
|||||||
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
|
- 统一收拢为我们先前在 Dashboard 页、JSON 工具箱制定的 p-4 space-y-4 标准极客桌面规格。
|
||||||
*/
|
*/
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300">
|
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300">
|
||||||
<PageHeader
|
|
||||||
title={t('pageTitle')}
|
|
||||||
subtitle={t('pageSubtitle')}
|
|
||||||
icon={<Code className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* 工具栏集成区 */}
|
{/* 工具栏集成区 */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ArrowRightLeft, Braces, GitCompareArrows, Minimize2 } from 'lucide-react';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import JsonDiffInput from './JsonDiffInput';
|
import JsonDiffInput from './JsonDiffInput';
|
||||||
import DiffResult from './DiffResult';
|
import DiffResult from './DiffResult';
|
||||||
import DiffNavigator from './DiffNavigator';
|
import DiffNavigator from './DiffNavigator';
|
||||||
@@ -99,22 +97,6 @@ export default function Index() {
|
|||||||
|
|
||||||
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
||||||
|
|
||||||
const modeTitles: Record<PageMode, { title: string; subtitle: string }> = {
|
|
||||||
diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' },
|
|
||||||
format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' },
|
|
||||||
yaml: { title: 'jsonFormat:yamlTitle', subtitle: 'jsonFormat:yamlSubtitle' },
|
|
||||||
toml: { title: 'jsonFormat:tomlTitle', subtitle: 'jsonFormat:tomlSubtitle' },
|
|
||||||
minify: { title: 'jsonFormat:minifyTitle', subtitle: 'jsonFormat:minifySubtitle' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const modeIcon: Record<PageMode, React.ReactNode> = {
|
|
||||||
diff: <GitCompareArrows className="h-4 w-4" />,
|
|
||||||
format: <Braces className="h-4 w-4" />,
|
|
||||||
yaml: <ArrowRightLeft className="h-4 w-4" />,
|
|
||||||
toml: <ArrowRightLeft className="h-4 w-4" />,
|
|
||||||
minify: <Minimize2 className="h-4 w-4" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
||||||
const r = jsonToYaml(text);
|
const r = jsonToYaml(text);
|
||||||
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
|
||||||
@@ -132,14 +114,6 @@ export default function Index() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
||||||
<PageHeader
|
|
||||||
title={t(modeTitles[pageMode].title)}
|
|
||||||
subtitle={t(modeTitles[pageMode].subtitle)}
|
|
||||||
icon={modeIcon[pageMode]}
|
|
||||||
iconColor="#3b82f6"
|
|
||||||
className="pb-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
value={pageMode}
|
value={pageMode}
|
||||||
onChange={(v: PageMode) => setPageMode(v)}
|
onChange={(v: PageMode) => setPageMode(v)}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Key } from 'lucide-react';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { parseJwt, stringifyJson } from '@/utils/jwt';
|
import { parseJwt, stringifyJson } from '@/utils/jwt';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
@@ -73,12 +71,6 @@ export default function Index() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300 select-none">
|
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300 select-none">
|
||||||
<PageHeader
|
|
||||||
title={t('jwt:pageTitle')}
|
|
||||||
subtitle={t('jwt:pageSubtitle')}
|
|
||||||
icon={<Key className="h-4 w-4" />} // 💡 规范锁死 Icon 宽高,抹杀闪烁
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* 输入终端 */}
|
{/* 输入终端 */}
|
||||||
<TextInputArea
|
<TextInputArea
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Code, Download, Printer, Trash2 } from 'lucide-react';
|
import { Download, Printer, Trash2 } from 'lucide-react';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -177,13 +176,6 @@ export default function MarkdownToHtmlPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300">
|
<div className="p-4 w-full flex flex-col space-y-4 select-none animate-in fade-in duration-300">
|
||||||
<PageHeader
|
|
||||||
title={t('pageTitle')}
|
|
||||||
subtitle={t('pageSubtitle')}
|
|
||||||
icon={<Code className="h-4 w-4" />}
|
|
||||||
className="pb-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
{/* 工具集成控制中枢 */}
|
{/* 工具集成控制中枢 */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center bg-secondary/40 rounded-xl border border-border/60 px-1.5 py-1.5 sm:h-12">
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import QrCodePage from '../index';
|
import QrCodePage from '../index';
|
||||||
|
|
||||||
// 💡 1. 规范回归:轻量级拦截 Mock lucide-react,杜绝一切在 JSDOM 虚拟环境中潜在的图标闪烁与属性未定义红牌
|
vi.mock('lucide-react', async (importOriginal) => {
|
||||||
vi.mock('lucide-react', () => ({
|
const actual = await importOriginal<typeof import('lucide-react')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
// 增量伪造需要高精嗅探的 QrCode 核心定位图标
|
||||||
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
|
QrCode: () => <div data-testid="mock-lucide-qrcode">Icon</div>,
|
||||||
Type: () => <div>Type</div>,
|
};
|
||||||
Upload: () => <div>Upload</div>,
|
});
|
||||||
Image: () => <div>Image</div>,
|
|
||||||
Trash2: () => <div>Trash2</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useLazyTranslation
|
// Mock useLazyTranslation
|
||||||
vi.mock('@/utils/useLazyTranslation', () => ({
|
vi.mock('@/utils/useLazyTranslation', () => ({
|
||||||
@@ -27,7 +27,7 @@ vi.mock('@/components/GlobalSnackbar', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock getEntryPointType
|
// Mock getEntryPointType(保留原厂其他特征配置,仅模拟入口路由环境)
|
||||||
vi.mock('@/config/features', async (importOriginal) => {
|
vi.mock('@/config/features', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/config/features')>();
|
const actual = await importOriginal<typeof import('@/config/features')>();
|
||||||
return {
|
return {
|
||||||
@@ -36,7 +36,7 @@ vi.mock('@/config/features', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock 子组件
|
// Mock 高频变化的子组件,收拢断言边界
|
||||||
vi.mock('@/components/QrCodePreview', () => ({
|
vi.mock('@/components/QrCodePreview', () => ({
|
||||||
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
|
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
|
||||||
}));
|
}));
|
||||||
@@ -45,7 +45,7 @@ vi.mock('@/components/ImageUploader', () => ({
|
|||||||
default: () => <div data-testid="image-uploader">ImageUploader</div>,
|
default: () => <div data-testid="image-uploader">ImageUploader</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock QRious
|
// Mock QRious 动态图像离屏生成引擎
|
||||||
vi.mock('qrious', () => ({
|
vi.mock('qrious', () => ({
|
||||||
default: vi.fn().mockImplementation(() => ({
|
default: vi.fn().mockImplementation(() => ({
|
||||||
toDataURL: () => 'data:image/png;base64,mock',
|
toDataURL: () => 'data:image/png;base64,mock',
|
||||||
@@ -53,6 +53,10 @@ vi.mock('qrious', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('QrCodePage', () => {
|
describe('QrCodePage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('应该默认渲染生成模式', () => {
|
it('应该默认渲染生成模式', () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
expect(screen.getByTestId('qr-code-preview')).toBeInTheDocument();
|
||||||
@@ -64,12 +68,6 @@ describe('QrCodePage', () => {
|
|||||||
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
|
expect(screen.getByText('qrCode:qrToUrl')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染页面标题', () => {
|
|
||||||
render(<QrCodePage />);
|
|
||||||
expect(screen.getByText('qrCode:pageTitle')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('qrCode:pageSubtitle')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('切换到解析模式应该渲染 ImageUploader', () => {
|
it('切换到解析模式应该渲染 ImageUploader', () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
|
fireEvent.click(screen.getByText('qrCode:qrToUrl'));
|
||||||
@@ -89,16 +87,11 @@ describe('QrCodePage', () => {
|
|||||||
|
|
||||||
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => {
|
it('应该渲染输入区域的系统标签(对齐新版 Label 机制)', () => {
|
||||||
render(<QrCodePage />);
|
render(<QrCodePage />);
|
||||||
// ✅ 完美对接:重构后不管是 Generate 还是 Parse 面板,
|
|
||||||
// 其外部包裹层里的标准独立 <Label /> 组件依然包含对应的语义文本,可以顺畅被 ByText 捕获!
|
|
||||||
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
|
expect(screen.getByText('qrCode:urlInputLabel')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该渲染双翼响应式卡片网格布局', () => {
|
it('应该渲染双翼响应式卡片网格布局', () => {
|
||||||
const { container } = render(<QrCodePage />);
|
const { container } = render(<QrCodePage />);
|
||||||
// 💡 修复点:废除极度脆弱的 '.grid.grid-cols-1.md\\:grid-cols-2' 类名全路径拼写抓取
|
|
||||||
// 改用健壮的语义属性查询,只需检测当前容器是否处于 grid 状态,就能无视未来的微调动效,
|
|
||||||
// 测试鲁棒性直接提升到工业最高标准!
|
|
||||||
const gridContainer = container.querySelector('.grid');
|
const gridContainer = container.querySelector('.grid');
|
||||||
expect(gridContainer).toBeInTheDocument();
|
expect(gridContainer).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-12
@@ -1,7 +1,5 @@
|
|||||||
import { QrCode as QrCodeIcon } from 'lucide-react'; // 💡 别名规整,防止与页面组件发生重名误判
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { QrCodeContext } from './contexts/QrCodeContext';
|
import { QrCodeContext } from './contexts/QrCodeContext';
|
||||||
import { useQrCode } from './hooks/useQrCode';
|
import { useQrCode } from './hooks/useQrCode';
|
||||||
@@ -26,15 +24,6 @@ export default function Index() {
|
|||||||
- 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排!
|
- 替换为标准的 p-4 呼吸内边距配合 flex flex-col space-y-4,接管系统级重排!
|
||||||
*/}
|
*/}
|
||||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
||||||
{/* 标题控制栏:追加微调 py-0.5,防范文字边缘截断 */}
|
|
||||||
<PageHeader
|
|
||||||
title={t('qrCode:pageTitle')}
|
|
||||||
subtitle={t('qrCode:pageSubtitle')}
|
|
||||||
icon={<QrCodeIcon className="h-4 w-4" />} // 💡 规范对齐:强制锁死 Icon 宽高,抹杀闪烁
|
|
||||||
iconColor={qrCodePageStyles.primaryColor}
|
|
||||||
className="pb-1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
|
{/* 流式中央控制切流卡:注入 sm 断点防御,防范单栏状态下发生变形 */}
|
||||||
<div className="w-full sm:w-fit pt-0.5">
|
<div className="w-full sm:w-fit pt-0.5">
|
||||||
<SwitchButtonGroup
|
<SwitchButtonGroup
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import RightClickRestorerPage from '../index';
|
||||||
|
|
||||||
|
const mockUnlock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../useRightClickRestorer', () => ({
|
||||||
|
useRightClickRestorer: () => ({
|
||||||
|
domain: 'example.com',
|
||||||
|
isLoading: false,
|
||||||
|
isUnlocked: false,
|
||||||
|
isUnsupported: false,
|
||||||
|
unlock: mockUnlock,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RightClickRestorerPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render current domain', () => {
|
||||||
|
render(<RightClickRestorerPage />);
|
||||||
|
expect(screen.getByText(/example\.com/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render locked status', () => {
|
||||||
|
render(<RightClickRestorerPage />);
|
||||||
|
expect(screen.getByText(/statusLocked/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call unlock when button clicked', () => {
|
||||||
|
render(<RightClickRestorerPage />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(mockUnlock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import { useRightClickRestorer } from '../useRightClickRestorer';
|
||||||
|
import { sendMessageToContent } from '@/utils/messages';
|
||||||
|
|
||||||
|
const mockTabsQuery = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/utils/messages', () => ({
|
||||||
|
MessageAction: {
|
||||||
|
RESTORE_RIGHT_CLICK: 'restoreRightClick',
|
||||||
|
QUERY_RIGHT_CLICK_STATUS: 'queryRightClickStatus',
|
||||||
|
},
|
||||||
|
sendMessageToContent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
|
||||||
|
chrome.tabs.query = mockTabsQuery;
|
||||||
|
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useRightClickRestorer', () => {
|
||||||
|
it('should load domain and query status', async () => {
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.domain).toBe('example.com');
|
||||||
|
expect(result.current.isUnlocked).toBe(false);
|
||||||
|
expect(result.current.isUnsupported).toBe(false);
|
||||||
|
expect(sendMessageToContent).toHaveBeenCalledWith('queryRightClickStatus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark internal pages as unsupported', async () => {
|
||||||
|
mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
|
||||||
|
chrome.tabs.query = mockTabsQuery;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.isUnsupported).toBe(true);
|
||||||
|
expect(result.current.isUnlocked).toBe(false);
|
||||||
|
expect(sendMessageToContent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unlock right click', async () => {
|
||||||
|
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: false });
|
||||||
|
vi.mocked(sendMessageToContent).mockResolvedValueOnce({ success: true, restored: true });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isUnlocked).toBe(true);
|
||||||
|
expect(sendMessageToContent).toHaveBeenLastCalledWith('restoreRightClick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not unlock unsupported pages', async () => {
|
||||||
|
mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
|
||||||
|
chrome.tabs.query = mockTabsQuery;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isUnlocked).toBe(false);
|
||||||
|
expect(sendMessageToContent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sendMessage failure gracefully', async () => {
|
||||||
|
vi.mocked(sendMessageToContent).mockRejectedValue(new Error('Connection failed'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRightClickRestorer());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.unlock();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isUnlocked).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Shield, ShieldCheck, MousePointerClick, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useRightClickRestorer } from './useRightClickRestorer';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
export default function RightClickRestorerPage() {
|
||||||
|
const { t } = useLazyTranslation('rightClickRestorer');
|
||||||
|
const { domain, isLoading, isUnlocked, isUnsupported, unlock } = useRightClickRestorer();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
|
||||||
|
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
|
||||||
|
{t('rightClickRestorer:loading')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 animate-in fade-in duration-300">
|
||||||
|
{/* Current Domain */}
|
||||||
|
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
|
||||||
|
<div className="p-4">
|
||||||
|
<Label className="text-sm font-medium">{t('rightClickRestorer:currentDomain')}</Label>
|
||||||
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
|
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
|
||||||
|
{domain || '—'}
|
||||||
|
</code>
|
||||||
|
{isUnsupported ? (
|
||||||
|
<Badge variant="destructive" className="gap-1 shrink-0">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{t('rightClickRestorer:unsupported')}
|
||||||
|
</Badge>
|
||||||
|
) : isUnlocked ? (
|
||||||
|
<Badge variant="default" className="gap-1 bg-green-600 hover:bg-green-700 shrink-0">
|
||||||
|
<ShieldCheck className="h-3 w-3" />
|
||||||
|
{t('rightClickRestorer:statusUnlocked')}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="gap-1 shrink-0">
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
{t('rightClickRestorer:statusLocked')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unlock Action */}
|
||||||
|
<div className="w-full rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden transition-all">
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{isUnsupported ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('rightClickRestorer:unsupportedDesc')}
|
||||||
|
</p>
|
||||||
|
<Button className="w-full gap-2" disabled variant="secondary">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
{t('rightClickRestorer:unsupported')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('rightClickRestorer:unlockDesc')}</p>
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => void unlock()}
|
||||||
|
disabled={isUnlocked}
|
||||||
|
variant={isUnlocked ? 'secondary' : 'default'}
|
||||||
|
>
|
||||||
|
<MousePointerClick className="h-4 w-4" />
|
||||||
|
{isUnlocked
|
||||||
|
? t('rightClickRestorer:alreadyUnlocked')
|
||||||
|
: t('rightClickRestorer:unlockBtn')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { MessageAction, sendMessageToContent } from '@/utils/messages';
|
||||||
|
|
||||||
|
const UNSUPPORTED_PROTOCOLS = new Set([
|
||||||
|
'chrome:',
|
||||||
|
'chrome-extension:',
|
||||||
|
'about:',
|
||||||
|
'edge:',
|
||||||
|
'brave:',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isUnsupportedPage(url: string | undefined): boolean {
|
||||||
|
if (!url) return true;
|
||||||
|
try {
|
||||||
|
const protocol = new URL(url).protocol;
|
||||||
|
return UNSUPPORTED_PROTOCOLS.has(protocol);
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseRightClickRestorerReturn {
|
||||||
|
domain: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
isUnsupported: boolean;
|
||||||
|
unlock: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRightClickRestorer(): UseRightClickRestorerReturn {
|
||||||
|
const [domain, setDomain] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
|
||||||
|
const [isUnsupported, setIsUnsupported] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
const url = tab?.url;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
setDomain(new URL(url).hostname);
|
||||||
|
} catch {
|
||||||
|
setDomain('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnsupportedPage(url)) {
|
||||||
|
setIsUnsupported(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
|
||||||
|
if (response?.success) {
|
||||||
|
setIsUnlocked(response.restored);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RightClickRestorer] Failed to load state:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unlock = useCallback(async () => {
|
||||||
|
if (isUnsupported) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
|
||||||
|
if (response?.success) {
|
||||||
|
setIsUnlocked(response.restored);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RightClickRestorer] Failed to unlock:', err);
|
||||||
|
}
|
||||||
|
}, [isUnsupported]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
isLoading,
|
||||||
|
isUnlocked,
|
||||||
|
isUnsupported,
|
||||||
|
unlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,17 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 自动刷新开关组件
|
||||||
|
*
|
||||||
|
* 用于 StorageCleaner 页面,控制是否自动刷新存储数据列表。
|
||||||
|
* 以卡片形式展示,左侧为标签文本,右侧为 shadcn/ui Switch 开关。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AutoRefreshToggle
|
||||||
|
* reloadAfterClean={reloadAfterClean}
|
||||||
|
* onChange={(checked) => setReloadAfterClean(checked)}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
// 引入官方的 Switch 原子组件
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
interface AutoRefreshToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
export interface AutoRefreshToggleProps extends Omit<
|
||||||
autoRefresh: boolean;
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
/** 开关的当前状态(受控) */
|
||||||
|
reloadAfterClean: boolean;
|
||||||
|
/** 状态变化时的回调函数 */
|
||||||
onChange: (checked: boolean) => void;
|
onChange: (checked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动刷新开关
|
||||||
|
*
|
||||||
|
* @param reloadAfterClean - 当前开关状态
|
||||||
|
* @param onChange - 状态变化回调,接收新的布尔值
|
||||||
|
* @param className - 额外的 CSS 类名,用于覆盖或扩展样式
|
||||||
|
* @param props - 透传给外层 div 的其他 HTML 属性
|
||||||
|
*/
|
||||||
export default function AutoRefreshToggle({
|
export default function AutoRefreshToggle({
|
||||||
autoRefresh,
|
reloadAfterClean,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -21,15 +48,12 @@ export default function AutoRefreshToggle({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// 外层包裹维持优雅的 shadcn 风格中性卡片,去除硬编码 mb-3 扩展灵活性
|
|
||||||
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring flex justify-between items-center',
|
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring flex justify-between items-center',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* 3. 使用标准的 shadcn/ui Label 组件:
|
{/* Label 与 Switch 通过 htmlFor + id 关联,支持点击文字触发开关 */}
|
||||||
绑定 htmlFor 建立安全的表单无障碍桥梁,使得用户点击文字也能触发开关联动
|
|
||||||
*/}
|
|
||||||
<Label
|
<Label
|
||||||
htmlFor="auto-refresh-switch"
|
htmlFor="auto-refresh-switch"
|
||||||
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
|
className="text-sm font-bold text-foreground cursor-pointer select-none tracking-tight"
|
||||||
@@ -37,16 +61,7 @@ export default function AutoRefreshToggle({
|
|||||||
{t('storageCleaner:autoRefresh')}
|
{t('storageCleaner:autoRefresh')}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{/* 4. 超进化:彻底废除 200 个字符的原生 checkbox 拼接!
|
<Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} />
|
||||||
完美调用 shadcn 的 Switch 组件。它会自动应用全站统一的主色(Primary)、
|
|
||||||
带阻尼的滑块硬件加速动效、以及教科书级别的 WAI-ARIA 无障碍键盘焦点提示。
|
|
||||||
*/}
|
|
||||||
<Switch
|
|
||||||
id="auto-refresh-switch"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
className="data-[state=checked]:bg-primary" // 如果依然需要特定的琥珀色可写 data-[state=checked]:bg-amber-500
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,29 +11,26 @@ import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
|||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { t } = useLazyTranslation('storageCleaner');
|
const { t } = useLazyTranslation('storageCleaner');
|
||||||
|
|
||||||
// 1. 完美对接全新重构、无需回调参数的纯净版状态 Hook
|
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
options,
|
options,
|
||||||
sizes,
|
sizes,
|
||||||
autoRefresh,
|
reloadAfterClean,
|
||||||
loading,
|
loading,
|
||||||
result,
|
result,
|
||||||
showConfirm,
|
showConfirm,
|
||||||
setShowConfirm,
|
setShowConfirm,
|
||||||
allSelected,
|
allSelected,
|
||||||
someSelected,
|
someSelected,
|
||||||
handleAutoRefreshChange,
|
handleReloadAfterCleanChange,
|
||||||
handleOptionChange,
|
handleOptionChange,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
handleClean,
|
handleClean,
|
||||||
} = useStorageCleaner();
|
} = useStorageCleaner();
|
||||||
|
|
||||||
// 计算当前的按钮锁定状态:没有任何一项被勾选,或者正在清理中,则禁用大按钮
|
|
||||||
const isButtonDisabled = !(someSelected || allSelected) || loading;
|
const isButtonDisabled = !(someSelected || allSelected) || loading;
|
||||||
|
|
||||||
// 2. 初始化骨架屏:全面升级为符合 shadcn 规范的无损微动效 Spinner
|
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
|
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full animate-in fade-in duration-200">
|
||||||
@@ -45,18 +42,12 @@ export default function Index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拦截非法域名或受限域名的错误提示页(ErrorDisplay 内部已在上一轮做好 p-4 居中)
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorDisplay error={error} />;
|
return <ErrorDisplay error={error} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* 3. 终极版页面根容器:
|
|
||||||
- 彻底灌入 p-4,全局对齐线向内收缩 16px,终结贴边惨剧。
|
|
||||||
- 用 space-y-3.5 替代块级外边距(mb-3 等),让所有卡片之间的垂直间距处于绝对一致的黄金比例。
|
|
||||||
*/
|
|
||||||
<div className="p-4 w-full flex flex-col space-y-3.5 animate-in fade-in duration-300">
|
<div className="p-4 w-full flex flex-col space-y-3.5 animate-in fade-in duration-300">
|
||||||
{/* 存储网格核心中控面板 (内部已满血复活半选状态) */}
|
|
||||||
<StorageOptionsGrid
|
<StorageOptionsGrid
|
||||||
options={options}
|
options={options}
|
||||||
sizes={sizes}
|
sizes={sizes}
|
||||||
@@ -66,14 +57,11 @@ export default function Index() {
|
|||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 自动刷新开关 */}
|
<AutoRefreshToggle
|
||||||
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
reloadAfterClean={reloadAfterClean}
|
||||||
|
onChange={handleReloadAfterCleanChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 4. 主行动按钮超进化:
|
|
||||||
- 彻底剥离 bg-amber-500,全面回归系统标准的 variant="destructive"。
|
|
||||||
- 享受高风险操作该有的危险红警示,完美契合上一轮重构的二次确认弹窗基调。
|
|
||||||
- 高级动态加载:当处于 cleaning 状态时,文字自动流转,且左侧自动淡入等宽 Loader2 图标。
|
|
||||||
*/}
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="default"
|
size="default"
|
||||||
@@ -91,10 +79,8 @@ export default function Index() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 动态清理结果返回反馈卡片 */}
|
|
||||||
<CleaningResult result={result} />
|
<CleaningResult result={result} />
|
||||||
|
|
||||||
{/* 二次风险防御确认弹窗 */}
|
|
||||||
<StorageCleanerConfirm
|
<StorageCleanerConfirm
|
||||||
open={showConfirm}
|
open={showConfirm}
|
||||||
onClose={() => setShowConfirm(false)}
|
onClose={() => setShowConfirm(false)}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||||
autoRefresh: true,
|
reloadAfterClean: true,
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
selectedTypes: DEFAULT_OPTIONS,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export interface UseStorageCleanerReturn {
|
|||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
options: StorageCleanerOptions;
|
options: StorageCleanerOptions;
|
||||||
sizes: Record<string, number>;
|
sizes: Record<string, number>;
|
||||||
autoRefresh: boolean;
|
reloadAfterClean: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
result: CleaningResult | null;
|
result: CleaningResult | null;
|
||||||
showConfirm: boolean;
|
showConfirm: boolean;
|
||||||
@@ -49,7 +49,7 @@ export interface UseStorageCleanerReturn {
|
|||||||
allSelected: boolean;
|
allSelected: boolean;
|
||||||
someSelected: boolean;
|
someSelected: boolean;
|
||||||
|
|
||||||
handleAutoRefreshChange: (checked: boolean) => void;
|
handleReloadAfterCleanChange: (checked: boolean) => void;
|
||||||
handleOptionChange: (key: keyof StorageCleanerOptions) => void;
|
handleOptionChange: (key: keyof StorageCleanerOptions) => void;
|
||||||
handleSelectAll: (checked: boolean) => void;
|
handleSelectAll: (checked: boolean) => void;
|
||||||
handleClean: () => Promise<void>;
|
handleClean: () => Promise<void>;
|
||||||
@@ -62,7 +62,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
const [sizes, setSizes] = useState<Record<string, number>>({});
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||||
@@ -117,7 +117,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
if (currentRequestId !== requestIdRef.current) return;
|
if (currentRequestId !== requestIdRef.current) return;
|
||||||
|
|
||||||
if (savedPrefs) {
|
if (savedPrefs) {
|
||||||
setAutoRefresh(savedPrefs.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
setReloadAfterClean(savedPrefs.reloadAfterClean ?? DEFAULT_PREFERENCES.reloadAfterClean);
|
||||||
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,26 +170,22 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
};
|
};
|
||||||
}, [debouncedLoadInfo]);
|
}, [debouncedLoadInfo]);
|
||||||
|
|
||||||
// 2. 超进化:防抖写盘管道 (Chrome Storage Debounce Pipeline)
|
|
||||||
// 用户疯狂点击勾选时,React 状态保持丝滑的 0 延迟同步,只有停下点击 500ms 后,才会真正发起一次 Chrome 存盘,配额永远安全。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 过滤掉首次初始化时的无意义写盘
|
|
||||||
if (isInitializing) return;
|
if (isInitializing) return;
|
||||||
|
|
||||||
if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
|
if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
|
||||||
storageTimerRef.current = setTimeout(async () => {
|
storageTimerRef.current = setTimeout(async () => {
|
||||||
await storageUtil
|
await storageUtil
|
||||||
.set('storageCleaner/preferences', {
|
.set('storageCleaner/preferences', {
|
||||||
autoRefresh,
|
reloadAfterClean,
|
||||||
selectedTypes: options,
|
selectedTypes: options,
|
||||||
})
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, 500);
|
}, 500);
|
||||||
}, [options, autoRefresh, isInitializing]);
|
}, [options, reloadAfterClean, isInitializing]);
|
||||||
|
|
||||||
// 3. 极速状态分发:同步函数化(去掉了原有的 async 声明,只负责触发状态)
|
const handleReloadAfterCleanChange = useCallback((checked: boolean) => {
|
||||||
const handleAutoRefreshChange = useCallback((checked: boolean) => {
|
setReloadAfterClean(checked);
|
||||||
setAutoRefresh(checked);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
||||||
@@ -207,7 +203,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 清理动作核心
|
|
||||||
const handleClean = useCallback(async () => {
|
const handleClean = useCallback(async () => {
|
||||||
if (loadingRef.current) return;
|
if (loadingRef.current) return;
|
||||||
|
|
||||||
@@ -222,7 +217,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||||
setResult(cleaningResult);
|
setResult(cleaningResult);
|
||||||
|
|
||||||
if (autoRefresh && cleaningResult.success) {
|
if (reloadAfterClean && cleaningResult.success) {
|
||||||
toast.success(t('storageCleaner:cleanSuccessReload'));
|
toast.success(t('storageCleaner:cleanSuccessReload'));
|
||||||
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
|
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
|
||||||
} else {
|
} else {
|
||||||
@@ -234,9 +229,8 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setShowConfirm(false);
|
setShowConfirm(false);
|
||||||
}
|
}
|
||||||
}, [options, autoRefresh, loadInfo, t]);
|
}, [options, reloadAfterClean, loadInfo, t]);
|
||||||
|
|
||||||
// 4. 精准的流式衍生计算收拢:完全切断垃圾内存常态分配
|
|
||||||
const totalSize = useMemo(() => {
|
const totalSize = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
(sizes.cookies || 0) +
|
(sizes.cookies || 0) +
|
||||||
@@ -259,7 +253,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
isInitializing,
|
isInitializing,
|
||||||
options,
|
options,
|
||||||
sizes,
|
sizes,
|
||||||
autoRefresh,
|
reloadAfterClean,
|
||||||
loading,
|
loading,
|
||||||
result,
|
result,
|
||||||
showConfirm,
|
showConfirm,
|
||||||
@@ -267,7 +261,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
|||||||
totalSize,
|
totalSize,
|
||||||
allSelected: selectionMetrics.all,
|
allSelected: selectionMetrics.all,
|
||||||
someSelected: selectionMetrics.some,
|
someSelected: selectionMetrics.some,
|
||||||
handleAutoRefreshChange,
|
handleReloadAfterCleanChange,
|
||||||
handleOptionChange,
|
handleOptionChange,
|
||||||
handleSelectAll,
|
handleSelectAll,
|
||||||
handleClean,
|
handleClean,
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { FileText } from 'lucide-react';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
import { formatByteSize, getTextStats } from '@/utils/textStatistics';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
@@ -29,14 +27,6 @@ export default function Index() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 w-full space-y-4 animate-in fade-in duration-300">
|
<div className="p-4 w-full space-y-4 animate-in fade-in duration-300">
|
||||||
{/* 头部区域 */}
|
|
||||||
<PageHeader
|
|
||||||
title={t('textStatistics:pageTitle')}
|
|
||||||
subtitle={t('textStatistics:pageSubtitle')}
|
|
||||||
icon={<FileText />}
|
|
||||||
iconColor="text-purple-500"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 文本输入区域 */}
|
{/* 文本输入区域 */}
|
||||||
<TextInputArea
|
<TextInputArea
|
||||||
value={text}
|
value={text}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
import type { UnitType } from './constants';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { cn } from '@/lib/utils'; // 引入标准的 shadcn 工具函数
|
import { cn } from '@/lib/utils'; // 引入标准的 shadcn 工具函数
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
import type { UnitType } from './constants';
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme'; // 彻底移除了非语义的 timestampPageStyles 依赖
|
import { DATE_FORMAT } from './constants';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||||
|
|
||||||
|
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||||
|
|
||||||
|
export type UnitType = 'ms' | 's';
|
||||||
|
export type ZoneType = (typeof ZONES)[number];
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { Clock } from 'lucide-react';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
import { ZONES } from '@/config/pageTheme';
|
import { ZONES } from './constants';
|
||||||
import LiveClock from './LiveClock';
|
import LiveClock from './LiveClock';
|
||||||
import ResultView from './ResultView';
|
import ResultView from './ResultView';
|
||||||
import { useTimestampConverter } from './useTimestampConverter';
|
import { useTimestampConverter } from './useTimestampConverter';
|
||||||
@@ -39,13 +37,6 @@ export default function Index() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
|
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
|
||||||
<div className="max-w-7xl mx-auto p-4 sm:p-6 space-y-4">
|
<div className="max-w-7xl mx-auto p-4 sm:p-6 space-y-4">
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title={t('timestamp:pageTitle')}
|
|
||||||
subtitle={t('timestamp:pageSubtitle')}
|
|
||||||
icon={<Clock />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 动态参考信息时钟条 */}
|
{/* 动态参考信息时钟条 */}
|
||||||
<LiveClock unit={unit} onUseNow={handleUseNow} />
|
<LiveClock unit={unit} onUseNow={handleUseNow} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import dayjs from '@/utils/dayjs';
|
import dayjs from '@/utils/dayjs';
|
||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
import type { UnitType, ZoneType } from './constants';
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
import { DATE_FORMAT } from './constants';
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -26,11 +26,7 @@
|
|||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
|
||||||
"types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"],
|
"types": ["chrome", "webextension-polyfill", "@testing-library/jest-dom", "vitest/globals"],
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// 确保包含你的源代码目录
|
// 确保包含你的源代码目录
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Vendored
+3
-2
@@ -11,7 +11,8 @@ export type PageType =
|
|||||||
| 'jsonDiff' // JSON 差异比较工具
|
| 'jsonDiff' // JSON 差异比较工具
|
||||||
| 'base64Converter' // Base64 转换器工具
|
| 'base64Converter' // Base64 转换器工具
|
||||||
| 'markdownToHtml' // Markdown 转 HTML 工具
|
| 'markdownToHtml' // Markdown 转 HTML 工具
|
||||||
| 'htmlToMarkdown'; // HTML 转 Markdown 工具
|
| 'htmlToMarkdown' // HTML 转 Markdown 工具
|
||||||
|
| 'rightClickRestorer'; // 右键菜单恢复工具
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON 工具页面子模式类型定义
|
* JSON 工具页面子模式类型定义
|
||||||
@@ -157,7 +158,7 @@ export interface FieldTypePreferences {
|
|||||||
*/
|
*/
|
||||||
export interface StorageCleanerPreferences {
|
export interface StorageCleanerPreferences {
|
||||||
/** 是否在清理后自动刷新页面 */
|
/** 是否在清理后自动刷新页面 */
|
||||||
autoRefresh: boolean;
|
reloadAfterClean: boolean;
|
||||||
/** 默认勾选的清理类型 */
|
/** 默认勾选的清理类型 */
|
||||||
selectedTypes: StorageCleanerOptions;
|
selectedTypes: StorageCleanerOptions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ describe('chromeStorage', () => {
|
|||||||
|
|
||||||
it('应该成功设置复杂对象值', async () => {
|
it('应该成功设置复杂对象值', async () => {
|
||||||
const preferences = {
|
const preferences = {
|
||||||
autoRefresh: false,
|
reloadAfterClean: false,
|
||||||
selectedTypes: {
|
selectedTypes: {
|
||||||
localStorage: true,
|
localStorage: true,
|
||||||
sessionStorage: true,
|
sessionStorage: true,
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { renderHook, waitFor } from '@testing-library/react';
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
import { preloadNamespaces } from '@/utils/useLazyTranslation';
|
|
||||||
|
|
||||||
// Mock i18n
|
// 强行砸碎当前模块的 Mock 封锁链,拉取真实的源码进行满血硬核测试
|
||||||
|
vi.unmock('@/utils/useLazyTranslation');
|
||||||
|
|
||||||
|
// 满血配置多端一致性常驻桩(WXT 规范)
|
||||||
|
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||||
|
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
|
||||||
|
// Mock 基础 i18n 底座
|
||||||
vi.mock('@/i18n', () => ({
|
vi.mock('@/i18n', () => ({
|
||||||
default: {
|
default: {
|
||||||
language: 'en',
|
language: 'en',
|
||||||
@@ -10,38 +17,62 @@ vi.mock('@/i18n', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock useTranslation
|
// Mock 核心 react-i18next 管道
|
||||||
vi.mock('react-i18next', () => ({
|
vi.mock('react-i18next', () => ({
|
||||||
useTranslation: vi.fn((ns: string[]) => ({
|
useTranslation: vi.fn((ns: string | string[]) => {
|
||||||
t: (key: string) => `${ns.join(',')}:${key}`,
|
const nsArray = Array.isArray(ns) ? ns : [ns];
|
||||||
|
return {
|
||||||
|
t: (key: string) => `${nsArray.join(',')}:${key}`,
|
||||||
i18n: { language: 'en' },
|
i18n: { language: 'en' },
|
||||||
ready: true,
|
ready: true,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock 动态导入
|
// Mock 动态本地化语言包 JSON 实体隔离区
|
||||||
const mockTimestampModule = { default: { 'timestamp.key': 'Timestamp Value' } };
|
const mockTimestampModule = { default: { 'timestamp.key': 'Timestamp Value' } };
|
||||||
const mockJwtModule = { default: { 'jwt.key': 'JWT Value' } };
|
const mockJwtModule = { default: { 'jwt.key': 'JWT Value' } };
|
||||||
|
const mockZhTimestampModule = { default: { 'timestamp.key': '时间戳值' } };
|
||||||
|
|
||||||
vi.mock('@/i18n/locales/en/timestamp.json', () => mockTimestampModule);
|
vi.mock('@/i18n/locales/en/timestamp.json', () => mockTimestampModule);
|
||||||
vi.mock('@/i18n/locales/en/jwt.json', () => mockJwtModule);
|
vi.mock('@/i18n/locales/en/jwt.json', () => mockJwtModule);
|
||||||
vi.mock('@/i18n/locales/zh/timestamp.json', () => ({ default: { 'timestamp.key': '时间戳值' } }));
|
vi.mock('@/i18n/locales/zh/timestamp.json', () => mockZhTimestampModule);
|
||||||
|
|
||||||
|
// 💡 1. 核心修复点:将 i18nMock 提升至【全域最高生存空间】!
|
||||||
|
// 确保下方所有的 describe 块和测试用例在词法作用域上均能 100% 自由消费。
|
||||||
|
let i18nMock: { language: string; addResourceBundle: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 💡 2. 抽取全局通用重置大闸,确保每个测试套件在冷启动时上下文绝对纯净
|
||||||
|
*/
|
||||||
|
const resetTestContext = async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// 动态捕获最新 i18n 实例状态
|
||||||
|
const i18nModule = await import('@/i18n');
|
||||||
|
i18nMock = i18nModule.default as any;
|
||||||
|
i18nMock.language = 'en'; // 强行重置为默认英文环境
|
||||||
|
|
||||||
|
// 安全清空生产文件里的内部私有缓存,杜绝跨用例状态株连
|
||||||
|
const lazyModule = await import('@/utils/useLazyTranslation');
|
||||||
|
if (
|
||||||
|
'__test_clearCache' in lazyModule &&
|
||||||
|
typeof (lazyModule as any).__test_clearCache === 'function'
|
||||||
|
) {
|
||||||
|
(lazyModule as any).__test_clearCache();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
describe('preloadNamespaces', () => {
|
describe('preloadNamespaces', () => {
|
||||||
let i18n: { addResourceBundle: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
await resetTestContext();
|
||||||
i18n = (await import('@/i18n')).default as any;
|
|
||||||
// 清除缓存
|
|
||||||
const { __test_clearCache } = await import('@/utils/useLazyTranslation');
|
|
||||||
__test_clearCache?.();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该加载指定的命名空间', async () => {
|
it('应该加载指定的命名空间', async () => {
|
||||||
|
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||||
await preloadNamespaces(['timestamp']);
|
await preloadNamespaces(['timestamp']);
|
||||||
|
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||||
'en',
|
'en',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
mockTimestampModule.default,
|
mockTimestampModule.default,
|
||||||
@@ -51,81 +82,69 @@ describe('preloadNamespaces', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('应该并行加载多个命名空间', async () => {
|
it('应该并行加载多个命名空间', async () => {
|
||||||
|
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||||
await preloadNamespaces(['timestamp', 'jwt']);
|
await preloadNamespaces(['timestamp', 'jwt']);
|
||||||
|
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledTimes(2);
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledTimes(2);
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||||
'en',
|
'en',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
mockTimestampModule.default,
|
mockTimestampModule.default,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
|
|
||||||
'en',
|
|
||||||
'jwt',
|
|
||||||
mockJwtModule.default,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该缓存已加载的命名空间,避免重复加载', async () => {
|
it('应该缓存已加载的命名空间,避免重复加载', async () => {
|
||||||
|
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||||
await preloadNamespaces(['timestamp']);
|
await preloadNamespaces(['timestamp']);
|
||||||
await preloadNamespaces(['timestamp']);
|
await preloadNamespaces(['timestamp']);
|
||||||
|
|
||||||
// 只应调用一次
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledTimes(1);
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该使用当前语言(中文)', async () => {
|
it('应该使用当前语言(中文)', async () => {
|
||||||
const i18nModule = await import('@/i18n');
|
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||||
(i18nModule.default as any).language = 'zh-CN';
|
i18nMock.language = 'zh-CN';
|
||||||
|
|
||||||
await preloadNamespaces(['timestamp']);
|
await preloadNamespaces(['timestamp']);
|
||||||
|
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||||
'zh',
|
'zh',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
{ 'timestamp.key': '时间戳值' },
|
mockZhTimestampModule.default,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 恢复
|
|
||||||
(i18nModule.default as any).language = 'en';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该跳过不存在的命名空间', async () => {
|
it('应该跳过不存在的命名空间', async () => {
|
||||||
|
const { preloadNamespaces } = await import('@/utils/useLazyTranslation');
|
||||||
await preloadNamespaces(['nonExistentNamespace']);
|
await preloadNamespaces(['nonExistentNamespace']);
|
||||||
|
|
||||||
// 不应调用 addResourceBundle
|
expect(i18nMock.addResourceBundle).not.toHaveBeenCalled();
|
||||||
expect(i18n.addResourceBundle).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useLazyTranslation', () => {
|
describe('useLazyTranslation', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
// 💡 3. 修复点:共享全局重置中枢,让第二个测试块在冷启动时也能合法刷新并拥有 i18nMock 实体
|
||||||
// 清除缓存
|
await resetTestContext();
|
||||||
const { __test_clearCache } = await import('@/utils/useLazyTranslation');
|
|
||||||
__test_clearCache?.();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应该在挂载时加载命名空间', async () => {
|
it('应该在挂载时加载命名空间', async () => {
|
||||||
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
|
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
|
||||||
const i18n = (await import('@/i18n')).default as any;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLazyTranslation('timestamp'));
|
const { result } = renderHook(() => useLazyTranslation('timestamp'));
|
||||||
|
|
||||||
// 初始状态应该是未加载
|
|
||||||
expect(result.current.isLoaded).toBe(false);
|
expect(result.current.isLoaded).toBe(false);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isLoaded).toBe(true);
|
expect(result.current.isLoaded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledWith(
|
// 💡 此时 i18nMock 在全域可读,彻底治愈 ReferenceError 报错!
|
||||||
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledWith(
|
||||||
'en',
|
'en',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
mockTimestampModule.default,
|
mockTimestampModule.default,
|
||||||
@@ -149,7 +168,6 @@ describe('useLazyTranslation', () => {
|
|||||||
|
|
||||||
it('应该支持多个命名空间', async () => {
|
it('应该支持多个命名空间', async () => {
|
||||||
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
|
const { useLazyTranslation } = await import('@/utils/useLazyTranslation');
|
||||||
const i18n = (await import('@/i18n')).default as any;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useLazyTranslation(['timestamp', 'jwt']));
|
const { result } = renderHook(() => useLazyTranslation(['timestamp', 'jwt']));
|
||||||
|
|
||||||
@@ -157,7 +175,7 @@ describe('useLazyTranslation', () => {
|
|||||||
expect(result.current.isLoaded).toBe(true);
|
expect(result.current.isLoaded).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(i18n.addResourceBundle).toHaveBeenCalledTimes(2);
|
expect(i18nMock.addResourceBundle).toHaveBeenCalledTimes(2);
|
||||||
expect(result.current.t('key')).toBe('timestamp,jwt:key');
|
expect(result.current.t('key')).toBe('timestamp,jwt:key');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ export enum MessageAction {
|
|||||||
RELOAD_TAB = 'reloadTab',
|
RELOAD_TAB = 'reloadTab',
|
||||||
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
|
||||||
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
CONTEXT_MENU_CLICKED = 'contextMenuClicked',
|
||||||
|
RESTORE_RIGHT_CLICK = 'restoreRightClick',
|
||||||
|
QUERY_RIGHT_CLICK_STATUS = 'queryRightClickStatus',
|
||||||
|
INJECT_MAIN_WORLD_SCRIPT = 'injectMainWorldScript',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageResponse {
|
export interface MessageResponse {
|
||||||
@@ -21,6 +24,11 @@ export interface ProtocolMap {
|
|||||||
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
[MessageAction.RELOAD_TAB](data: { tabId: number; delay?: number }): MessageResponse;
|
||||||
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
[MessageAction.SIDE_PANEL_STATE_CHANGED](data: { isOpen: boolean }): void;
|
||||||
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
[MessageAction.CONTEXT_MENU_CLICKED](data: ContextMenuClickedPayload): void;
|
||||||
|
[MessageAction.RESTORE_RIGHT_CLICK](data: undefined): MessageResponse & { restored: boolean };
|
||||||
|
[MessageAction.QUERY_RIGHT_CLICK_STATUS](
|
||||||
|
data: undefined,
|
||||||
|
): MessageResponse & { restored: boolean };
|
||||||
|
[MessageAction.INJECT_MAIN_WORLD_SCRIPT](data: undefined): MessageResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const localeModules: Record<
|
|||||||
base64Converter: () => import('@/i18n/locales/zh/base64Converter.json'),
|
base64Converter: () => import('@/i18n/locales/zh/base64Converter.json'),
|
||||||
markdownToHtml: () => import('@/i18n/locales/zh/markdownToHtml.json'),
|
markdownToHtml: () => import('@/i18n/locales/zh/markdownToHtml.json'),
|
||||||
htmlToMarkdown: () => import('@/i18n/locales/zh/htmlToMarkdown.json'),
|
htmlToMarkdown: () => import('@/i18n/locales/zh/htmlToMarkdown.json'),
|
||||||
|
rightClickRestorer: () => import('@/i18n/locales/zh/rightClickRestorer.json'),
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
timestamp: () => import('@/i18n/locales/en/timestamp.json'),
|
timestamp: () => import('@/i18n/locales/en/timestamp.json'),
|
||||||
@@ -30,6 +31,7 @@ const localeModules: Record<
|
|||||||
base64Converter: () => import('@/i18n/locales/en/base64Converter.json'),
|
base64Converter: () => import('@/i18n/locales/en/base64Converter.json'),
|
||||||
markdownToHtml: () => import('@/i18n/locales/en/markdownToHtml.json'),
|
markdownToHtml: () => import('@/i18n/locales/en/markdownToHtml.json'),
|
||||||
htmlToMarkdown: () => import('@/i18n/locales/en/htmlToMarkdown.json'),
|
htmlToMarkdown: () => import('@/i18n/locales/en/htmlToMarkdown.json'),
|
||||||
|
rightClickRestorer: () => import('@/i18n/locales/en/rightClickRestorer.json'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export default defineConfig({
|
|||||||
'cookies',
|
'cookies',
|
||||||
'sidePanel',
|
'sidePanel',
|
||||||
'contextMenus',
|
'contextMenus',
|
||||||
|
'alarms',
|
||||||
],
|
],
|
||||||
host_permissions: ['<all_urls>'],
|
host_permissions: ['<all_urls>'],
|
||||||
action: {
|
action: {
|
||||||
|
|||||||
Reference in New Issue
Block a user