Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ba57c070f9 | |||
| 34030e5f6a | |||
| 8b11dcd0ce | |||
| 67c0c30a7a | |||
| a3b2bfb43a | |||
| 81a2e793ba | |||
| 756ece31d3 | |||
| 7efed8673a | |||
| 88bebfa1b8 | |||
| 272bcae904 | |||
| 539f9648b1 | |||
| 7e58b5688f | |||
| 89e33f6273 | |||
| 9601afdd49 | |||
| ae0b923e11 | |||
| 917fca5947 | |||
| d2e5e6b45d | |||
| 336b62256d | |||
| 7ddc761df4 | |||
| 67553afe9c | |||
| 02aef0c7b1 | |||
| 94158f016b | |||
| dd03d9391f | |||
| 21e44cb889 | |||
| b3dd7146ad | |||
| 2ced38769b | |||
| 8406b03dd6 | |||
| da667ccb13 | |||
| 1bfb7d4e6d | |||
| b3c2fbccf5 | |||
| b29e9417cc | |||
| cf0d19914c | |||
| 6b2ac33b41 | |||
| 02a611e49b | |||
| 72883b5d1d | |||
| c7beebf6e0 | |||
| de7da9f1db | |||
| 5b9a0f59d3 | |||
| 3e1753da23 | |||
| 6763cd7862 | |||
| e2dd5f5915 | |||
| 1bc3ee59a0 | |||
| 6711783975 | |||
| 427e668e7c | |||
| 37618eb71f | |||
| d2334c5e95 | |||
| a4533be2c1 | |||
| 83bf9dd342 | |||
| 8902bc0f10 | |||
| 4753a7b54d | |||
| 7f9bf2334c | |||
| 6bd05aa6f1 | |||
| bdb40343b1 | |||
| 9a9336aea8 | |||
| 3eb00d8a29 | |||
| bb27577278 | |||
| 141e206b62 | |||
| 689c3faf16 | |||
| f5e8e96830 | |||
| f1f0047928 | |||
| f76fc3c140 | |||
| 9ed84382ea | |||
| 4a382144d1 | |||
| a654e64fe0 | |||
| ed8720e63e | |||
| 179a3d31f4 | |||
| 7cd99876d7 | |||
| 6b0de96b18 | |||
| 1df53323d0 | |||
| a5c34e5428 | |||
| 72f899477c | |||
| 8e2ac5200d | |||
| 604fbb4d1f | |||
| 704ec7289d | |||
| e4fac241f1 | |||
| 4b339fc628 | |||
| 70580a9a63 | |||
| 99a0400db3 | |||
| df0b9440ca | |||
| c5b51a9b33 | |||
| bc560cc453 | |||
| 2870e01610 | |||
| 9c03c585a8 | |||
| 0427f16cc9 | |||
| e9a6fa3dd8 | |||
| 00fe3eddd9 | |||
| 8e920c2a94 | |||
| 387a95d6a6 | |||
| 71995037dd | |||
| 8e56ebff6e | |||
| 5868316c3a | |||
| 8ec584c85f | |||
| c1890960ea | |||
| 3b98c9438e | |||
| 471e17eedf | |||
| 3fb99ea49f | |||
| 3d375f78f3 | |||
| 373fc0496c | |||
| 7167a43763 | |||
| 0cf8bae9f1 | |||
| a6dcdddf66 | |||
| 3c9c9e740e | |||
| ff964e3608 | |||
| 9d1d8a362a | |||
| 2a0a5658b5 | |||
| 57ea4d9858 | |||
| 8d36f21f1b | |||
| 60537f6e2e | |||
| c039475119 | |||
| fb5f98dd3b | |||
| af0089c980 | |||
| 4850c92365 | |||
| 0379f96e80 | |||
| be5e2f02ee | |||
| bbd0507bcd | |||
| 9a947d7431 | |||
| 84ffd2a132 | |||
| 2cd21973f3 | |||
| 979b45a898 | |||
| 8bc11eb696 | |||
| 71b9dcc35c | |||
| f4c2f6d374 |
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"WebSearch",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(npm run:*)",
|
"Bash(npx vitest:*)",
|
||||||
"Bash(git show-ref:*)",
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(git reset:*)",
|
||||||
"Bash(git checkout:*)",
|
"Bash(git checkout:*)",
|
||||||
"mcp__plugin_playwright_playwright__browser_navigate"
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx eslint:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(pnpm typecheck *)",
|
||||||
|
"Bash(pnpm test *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization)
|
||||||
|
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
|
||||||
|
setup:
|
||||||
|
name: Prepare Dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
cache-key: ${{ steps.cache-info.outputs.key }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
# 建立基于 package-lock.json 唯一哈希的缓存大闸
|
||||||
|
- name: Cache Node Modules
|
||||||
|
id: cache-nodemodules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-v22-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Output Cache Key
|
||||||
|
id: cache-info
|
||||||
|
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# 💡 2. 静态语法质检节点(依赖前置节点完成)
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance)
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.browser }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
|
||||||
|
needs: [ lint, typecheck, test ]
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
|
||||||
|
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
|
||||||
|
browser: [ chrome, firefox ]
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
|
- name: Build Extension (${{ matrix.browser }})
|
||||||
|
run: |
|
||||||
|
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
||||||
|
npm run build:${{ matrix.browser }}
|
||||||
|
else
|
||||||
|
npm run build -- --browser ${{ matrix.browser }}
|
||||||
|
fi
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
|
||||||
|
|
||||||
name: Node.js CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [22.x]
|
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm run build --if-present
|
|
||||||
# - run: npm test
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Phase 1: 依赖准备 ───────────────────
|
||||||
|
setup:
|
||||||
|
name: Prepare Dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
id: cache-nodemodules
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-release-v22-
|
||||||
|
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# ── Phase 2: 质量检查 ──────────────────
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
|
- run: npm run typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
# ── Phase 3: 打包 ───────────────────────
|
||||||
|
build-extension:
|
||||||
|
name: Package Extension
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- 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
|
||||||
|
run: |
|
||||||
|
npm run zip
|
||||||
|
npm run zip:firefox
|
||||||
|
|
||||||
|
# 🔍 调试:确认 zip 文件位置
|
||||||
|
- 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
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: release-archives/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# ── Phase 4: 发布 ───────────────────────
|
||||||
|
release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-extension
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Extension Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: release-artifacts
|
||||||
|
|
||||||
|
# 🔍 调试:确认下载成功
|
||||||
|
- name: Verify downloaded artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== release-artifacts/ ==="
|
||||||
|
ls -la release-artifacts/
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: "v${{ steps.version.outputs.version }}"
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
release-artifacts/*.zip
|
||||||
+7
-1
@@ -13,7 +13,6 @@ stats.html
|
|||||||
stats-*.json
|
stats-*.json
|
||||||
.wxt
|
.wxt
|
||||||
.vitest
|
.vitest
|
||||||
.claude
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -25,3 +24,10 @@ stats-*.json
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.trae/*
|
||||||
|
.workbuddy/*
|
||||||
|
.qoder/*
|
||||||
|
dev/*
|
||||||
|
|
||||||
|
docs/*
|
||||||
|
|||||||
@@ -1,143 +1,111 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
WXT 浏览器扩展项目 (React 19 + TypeScript)。
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
|
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
|
||||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
|
||||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
|
||||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
|
||||||
- `npm run zip` - 打包 Chrome 扩展
|
|
||||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
|
||||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
|
||||||
|
|
||||||
### 测试相关
|
|
||||||
|
|
||||||
- `npm run test` - 运行所有测试(单次执行)
|
|
||||||
- `npm run test:watch` - 运行测试并监听文件变化
|
|
||||||
- `npm run test:coverage` - 运行测试并生成覆盖率报告
|
|
||||||
|
|
||||||
**运行单个测试文件:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx vitest run components/__tests__/CopyButton.test.tsx
|
npm run dev # Chrome 开发模式 (HMR)
|
||||||
|
npm run dev:firefox # Firefox 开发模式
|
||||||
|
npm run build # Chrome 生产构建
|
||||||
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
|
npm run lint # ESLint (--max-warnings=0)
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run test # vitest run (单次执行)
|
||||||
|
npm run test:watch # vitest 监视模式
|
||||||
|
npm run test:coverage # 带覆盖率的测试
|
||||||
```
|
```
|
||||||
|
|
||||||
**测试技术栈:**
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
- Vitest - 测试框架
|
## 验证流程
|
||||||
- @testing-library/react - React 组件测试
|
|
||||||
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent)
|
|
||||||
- jsdom - 浏览器环境模拟
|
|
||||||
|
|
||||||
### 依赖与准备
|
CI 执行顺序: `setup → lint/typecheck/test(并行) → build` (build 依赖前三者)。
|
||||||
|
|
||||||
- `npm install` - 安装依赖
|
Pre-commit hook (`.husky/pre-commit` 调用 `lint-staged`):
|
||||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
|
||||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
|
||||||
|
|
||||||
## 项目架构
|
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`): `eslint --fix --max-warnings=0 --no-warn-ignored` → `prettier --write`
|
||||||
|
- 其他文件 (`*.{json,css,scss,md}`): `prettier --write`
|
||||||
|
|
||||||
### 技术栈
|
提交前确保 `lint` 和 `typecheck` 通过。
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
## WXT 生成文件
|
||||||
- **前端**: React 19 + TypeScript
|
|
||||||
- **UI 库**: Material UI (MUI)
|
|
||||||
- **状态管理**: React Hooks
|
|
||||||
- **路由**: React Router DOM
|
|
||||||
|
|
||||||
### 目录结构
|
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
|
||||||
|
- 生产构建输出到 `.output/` 目录。
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── components/ # 可复用 UI 组件
|
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||||
│ ├── CopyButton.tsx # 复制按钮组件
|
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
||||||
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
|
pages/ # 功能页面组件 (懒加载)
|
||||||
│ ├── Navbar.tsx # 导航栏组件
|
components/ # 可复用 UI 组件
|
||||||
│ ├── RoutePersistence.tsx # 路由持久化组件
|
providers/ # React Context (Router, Theme 等)
|
||||||
│ ├── TimestampExecution.tsx # 时间戳执行组件
|
hooks/ # 自定义 React Hooks
|
||||||
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
|
utils/ # 工具函数与服务抽象
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
types/ # TypeScript 类型声明
|
||||||
│ ├── background.ts # 后台脚本(主进程)
|
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
|
||||||
│ ├── content.ts # 内容脚本(注入到页面)
|
|
||||||
│ └── popup/ # 扩展弹窗界面
|
|
||||||
│ ├── App.tsx # 弹窗主应用
|
|
||||||
│ ├── main.tsx # 弹窗入口
|
|
||||||
│ └── pages/ # 弹窗页面
|
|
||||||
│ ├── TestPage.tsx # 测试页面
|
|
||||||
│ └── TimestampPage.tsx # 时间戳工具页面
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
|
||||||
│ ├── dayjs.ts # 日期处理工具
|
|
||||||
│ └── messages.tsx # 消息通信工具
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # 存储相关类型
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心功能实现
|
## 关键架构决策
|
||||||
|
|
||||||
#### 1. 时间戳转换工具
|
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||||
|
渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。
|
||||||
|
|
||||||
- 位置: `components/` 目录下的时间戳相关组件
|
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
|
||||||
- 依赖: dayjs 库进行日期处理
|
Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式
|
|
||||||
|
|
||||||
#### 2. 通信系统
|
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
||||||
|
|
||||||
- 位置: `utils/messages.tsx`
|
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
|
||||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
|
|
||||||
|
|
||||||
#### 3. 数据存储
|
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||||
|
|
||||||
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
|
## 测试环境
|
||||||
|
|
||||||
### 关键配置文件
|
- 环境: jsdom
|
||||||
|
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||||
|
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||||
|
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||||
|
- `react-i18next` (返回 key 作为翻译)
|
||||||
|
- `@/utils/useLazyTranslation` (返回 `ns:key` 格式翻译)
|
||||||
|
- `window.matchMedia`
|
||||||
|
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
|
|
||||||
#### wxt.config.ts
|
## i18n
|
||||||
|
|
||||||
- 配置 WXT 框架参数
|
- 命名空间: `common` (默认), `features`
|
||||||
- 启用 React 模块
|
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
||||||
- 配置浏览器扩展权限
|
- 语言: `zh` (默认), `en`
|
||||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
|
||||||
|
|
||||||
#### manifest 权限
|
## 新功能开发清单
|
||||||
|
|
||||||
```typescript
|
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
permissions: [
|
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
||||||
'storage', // 存储权限
|
3. 在 `pages/` 创建页面组件 (懒加载)
|
||||||
'unlimitedStorage', // 无限制存储
|
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
|
||||||
'clipboardWrite', // 剪贴板写入
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
'activeTab', // 当前标签页
|
6. 添加对应的单元测试
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'debugger', // 调试器
|
|
||||||
],
|
|
||||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发注意事项
|
## 代码规范
|
||||||
|
|
||||||
### 扩展入口点
|
- 禁止使用 `any` (测试文件除外)
|
||||||
|
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||||
|
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||||
|
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||||
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||||
|
|
||||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
## 技术栈版本
|
||||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
|
||||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
|
||||||
|
|
||||||
### 浏览器兼容性
|
- WXT: ^0.20.26
|
||||||
|
- React: ^19.2.6
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
- Tailwind CSS: ^3.4.19
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
- shadcn/ui (基于 Radix UI + class-variance-authority)
|
||||||
|
- TypeScript: ^5.9.3
|
||||||
### 代码质量
|
- Vitest: ^4.1.7
|
||||||
|
- i18next: ^26.2.0
|
||||||
- 使用 ESLint 进行代码检查
|
|
||||||
- Husky 用于 Git 钩子管理
|
|
||||||
- Lint-staged 确保暂存文件符合规范
|
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
|
|
||||||
|
|
||||||
## 核心命令
|
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
|
||||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
|
||||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
|
||||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
|
||||||
- `npm run zip` - 打包 Chrome 扩展
|
|
||||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
|
||||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
|
||||||
|
|
||||||
### 依赖与准备
|
|
||||||
|
|
||||||
- `npm install` - 安装依赖
|
|
||||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
|
||||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- GitHub Actions 配置: `.github/workflows/node.js.yml`
|
|
||||||
- 在 main 分支推送或 PR 时触发
|
|
||||||
- 使用 Node.js 22.x 运行 build
|
|
||||||
- 测试命令当前被注释(项目暂无测试)
|
|
||||||
|
|
||||||
## 项目架构
|
|
||||||
|
|
||||||
### 技术栈
|
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
|
||||||
- **前端**: React 19 + TypeScript
|
|
||||||
- **UI 库**: Material UI (MUI)
|
|
||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
|
||||||
- **通信**: @webext-core/messaging
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
|
|
||||||
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
|
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
|
||||||
│ │ ├── App.tsx # 弹窗主应用
|
|
||||||
│ │ ├── main.tsx # 弹窗入口
|
|
||||||
│ │ ├── index.html # 弹窗 HTML
|
|
||||||
│ │ └── pages/ # 弹窗页面
|
|
||||||
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
|
|
||||||
│ └── options/ # 选项页面(当前为静态 HTML)
|
|
||||||
│ └── index.html # 选项页 HTML
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
|
|
||||||
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
|
|
||||||
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging)
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # StorageSchema 类型定义
|
|
||||||
├── constants/ # 常量定义(当前为空)
|
|
||||||
└── public/ # 静态资源
|
|
||||||
```
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
|
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
|
||||||
- 时间戳 → 日期时间转换
|
|
||||||
- 日期时间 → 时间戳转换
|
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
|
||||||
- 一键复制功能
|
|
||||||
- 输入验证和错误提示
|
|
||||||
|
|
||||||
### 扩展入口点
|
|
||||||
|
|
||||||
- **后台脚本** (`entrypoints/background.ts`):
|
|
||||||
- 监听扩展安装/更新事件
|
|
||||||
- 自动向所有有效标签页注入内容脚本
|
|
||||||
- 过滤受限协议(chrome://, about:// 等)
|
|
||||||
|
|
||||||
- **内容脚本** (`entrypoints/content.ts`):
|
|
||||||
- 匹配所有 URL (`<all_urls>`)
|
|
||||||
- 在文档开始时运行
|
|
||||||
- 当前为占位符,无实际逻辑
|
|
||||||
|
|
||||||
- **弹窗** (`entrypoints/popup/`):
|
|
||||||
- 主入口显示 TimestampPage
|
|
||||||
- 提供时间戳转换的完整功能
|
|
||||||
|
|
||||||
- **选项页** (`entrypoints/options/`):
|
|
||||||
- 当前为静态 HTML 页面
|
|
||||||
- 可扩展为设置界面
|
|
||||||
|
|
||||||
### 数据存储
|
|
||||||
|
|
||||||
使用 Chrome Storage API 进行持久化存储:
|
|
||||||
|
|
||||||
- 类型安全的封装 (`utils/chromeStorage.ts`)
|
|
||||||
- 基于接口定义的 Schema (`types/storage.d.ts`)
|
|
||||||
- 当前支持的存储键:
|
|
||||||
- `app/lastRoute`: 上次访问的路由
|
|
||||||
- `app/theme`: 主题设置
|
|
||||||
|
|
||||||
### 消息通信
|
|
||||||
|
|
||||||
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
|
|
||||||
|
|
||||||
- 定义在 `utils/messages.tsx`
|
|
||||||
- 当前 ProtocolMap 为空(预留接口)
|
|
||||||
|
|
||||||
### 关键配置文件
|
|
||||||
|
|
||||||
#### wxt.config.ts
|
|
||||||
|
|
||||||
- 启用 React 模块 (`@wxt-dev/module-react`)
|
|
||||||
- 配置 manifest 权限和 host_permissions
|
|
||||||
- 使用 Terser 压缩(强制 ASCII 编码)
|
|
||||||
- 配置图标和选项页
|
|
||||||
|
|
||||||
#### manifest 权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage', // Chrome Storage
|
|
||||||
'unlimitedStorage', // 无限制存储
|
|
||||||
'clipboardWrite', // 剪贴板写入(复制功能)
|
|
||||||
'activeTab', // 当前标签页访问
|
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'debugger', // 调试器权限
|
|
||||||
],
|
|
||||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发注意事项
|
|
||||||
|
|
||||||
### 浏览器兼容性
|
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- 使用 ESLint 进行代码检查(零警告)
|
|
||||||
- Husky 用于 Git 钩子管理
|
|
||||||
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier)
|
|
||||||
- Prettier 用于代码格式化
|
|
||||||
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
|
|
||||||
|
|
||||||
### TypeScript 配置
|
|
||||||
|
|
||||||
- 严格模式开启(`strict: true`)
|
|
||||||
- `noImplicitAny` 设置为 `false`(允许隐式 any)
|
|
||||||
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`)
|
|
||||||
- 模块解析模式:Bundler
|
|
||||||
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
|
|
||||||
|
|
||||||
### 项目历史
|
|
||||||
|
|
||||||
近期重构(根据 git 历史):
|
|
||||||
|
|
||||||
- 移除了录制回放功能
|
|
||||||
- 移除了测试页面
|
|
||||||
- 精简为单页面时间戳工具
|
|
||||||
- 将 storage 工具类重命名为 storageUtil
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# Testing Tools 项目指南
|
|
||||||
|
|
||||||
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
|
|
||||||
|
|
||||||
## 1. 项目概览
|
|
||||||
|
|
||||||
- **名称**: Testing Tools
|
|
||||||
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
|
||||||
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
|
|
||||||
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
|
|
||||||
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
|
|
||||||
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
|
|
||||||
|
|
||||||
## 2. 项目结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
├── .github/ # CI/CD 工作流
|
|
||||||
├── .husky/ # Git Hooks (pre-commit linting)
|
|
||||||
├── assets/ # 静态资源 (SVG 等)
|
|
||||||
├── components/ # 复用 UI 组件
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台 Service Worker 逻辑
|
|
||||||
│ ├── content.ts # 内容脚本注入逻辑
|
|
||||||
│ ├── popup/ # 扩展弹出层 (主要功能区)
|
|
||||||
│ └── options/ # 扩展选项页面
|
|
||||||
├── types/ # 全局 TypeScript 类型声明
|
|
||||||
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
|
|
||||||
├── wxt.config.ts # WXT 框架与 Manifest 配置
|
|
||||||
└── package.json # 依赖管理与脚本
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 开发规范与风格约定
|
|
||||||
|
|
||||||
### 3.1 UI 设计语言
|
|
||||||
|
|
||||||
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
|
|
||||||
- **MUI 定制**:
|
|
||||||
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
|
|
||||||
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`。
|
|
||||||
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
|
|
||||||
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
|
|
||||||
- **布局**: 优先使用 `Stack` 和 `Box` 进行布局,确保自上而下的操作流顺畅。
|
|
||||||
|
|
||||||
### 3.2 技术选型惯例
|
|
||||||
|
|
||||||
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
|
|
||||||
- **状态管理**: 优先使用 React 原生 `useState` 和 `useMemo`。
|
|
||||||
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
|
|
||||||
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
|
|
||||||
|
|
||||||
## 4. 关键指令
|
|
||||||
|
|
||||||
### 4.1 开发与调试
|
|
||||||
|
|
||||||
- `npm run dev`: 启动 Chrome 扩展开发模式。
|
|
||||||
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
|
|
||||||
|
|
||||||
### 4.2 构建与检查
|
|
||||||
|
|
||||||
- `npm run build`: 构建生产版本。
|
|
||||||
- `npm run compile`: TypeScript 类型检查。
|
|
||||||
- `npm run lint`: ESLint 代码风格检查。
|
|
||||||
|
|
||||||
## 5. 权限与清单 (Manifest)
|
|
||||||
|
|
||||||
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`。
|
|
||||||
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
|
|
||||||
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
|
|
||||||
|
|
||||||
## 6. 维护者提示
|
|
||||||
|
|
||||||
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Testing Tools
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,121 +1,159 @@
|
|||||||
# Testing Tools Browser Extension
|
# Testing Tools Browser Extension
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。
|
这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具.
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持.
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### 时间戳转换工具
|
### 🚀 Dashboard 首页
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
- **工具导航**: 快速访问所有可用工具.
|
||||||
- 日期与时间戳之间的双向转换
|
- **个性化定制**: 支持自定义工具的排序和可见性.
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
|
||||||
- 一键复制转换结果
|
|
||||||
- 输入验证和错误提示
|
### ⏰ 时间戳转换工具
|
||||||
|
|
||||||
|
- **实时显示**: 毫秒级精度显示当前系统时间.
|
||||||
|
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
|
||||||
|
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
|
||||||
|
- **快捷操作**: 一键复制转换结果,支持多种格式.
|
||||||
|
|
||||||
|
### 🧹 存储清理工具
|
||||||
|
|
||||||
|
- **智能识别**: 自动检测并显示当前活动标签页的域名.
|
||||||
|
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
|
||||||
|
- **细粒度控制**: 可根据需要选择特定的清理项.
|
||||||
|
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
|
||||||
|
|
||||||
|
### 📝 文本统计工具
|
||||||
|
|
||||||
|
- **实时分析**: 键入即统计,无需额外操作.
|
||||||
|
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
|
||||||
|
- **性能优化**: 采用高性能分词算法,支持大文本处理.
|
||||||
|
|
||||||
|
### 🔑 JWT 解析工具
|
||||||
|
|
||||||
|
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
|
||||||
|
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
|
||||||
|
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
||||||
|
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
||||||
|
|
||||||
|
### 🖱️ 右键恢复工具
|
||||||
|
|
||||||
|
- **当前页面检测**: 自动识别当前活动标签页的域名.
|
||||||
|
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
|
||||||
|
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
|
||||||
|
|
||||||
|
### 🖼️ 二维码工具
|
||||||
|
|
||||||
|
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||||
|
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||||
|
|
||||||
|
### 🔄 JSON 工具
|
||||||
|
|
||||||
|
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
|
||||||
|
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
|
||||||
|
|
||||||
|
### 🧰 Base64 转换器
|
||||||
|
|
||||||
|
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
|
||||||
|
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||||
|
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||||
|
|
||||||
|
### 📝 Markdown ↔ HTML 转换
|
||||||
|
|
||||||
|
- **Markdown 转 HTML**: 实时预览 Markdown 渲染结果,支持导出 HTML 源码.
|
||||||
|
- **HTML 转 Markdown**: 将 HTML 内容转换为 Markdown 格式.
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit)
|
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||||
- **前端**: React 19 + TypeScript
|
- **前端**: React 19 + TypeScript
|
||||||
- **UI 库**: Material UI
|
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||||
|
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||||
|
- **国际化**: i18next + react-i18next
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
|
- **测试**: Vitest + Testing Library
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
├── components/ # 可复用 React 组件
|
||||||
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
|
├── config/ # 应用配置
|
||||||
│ ├── options/ # 选项页面
|
│ └── features.tsx # 功能定义与路由映射
|
||||||
│ ├── background.ts # 后台脚本
|
├── entrypoints/ # 扩展程序入口点
|
||||||
│ └── content.ts # 内容脚本
|
│ ├── popup/ # 点击图标弹出的主界面
|
||||||
├── utils/ # 工具函数(存储、日期处理、消息通信)
|
│ ├── options/ # 扩展程序设置页面
|
||||||
├── types/ # TypeScript 类型定义
|
│ ├── sidepanel/ # 浏览器侧边栏集成
|
||||||
├── public/ # 静态资源
|
│ ├── background.ts # 后台 Service Worker
|
||||||
├── wxt.config.ts # WXT 配置文件
|
│ └── content.ts # 网页注入脚本
|
||||||
├── package.json # 项目依赖和脚本
|
├── pages/ # 各功能模块的页面组件
|
||||||
└── README.md # 项目说明文档
|
├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||||
|
├── hooks/ # 自定义 React Hooks
|
||||||
|
├── utils/ # 工具函数与服务抽象
|
||||||
|
├── types/ # TypeScript 类型声明
|
||||||
|
├── lib/ # 通用工具函数 (cn, utils 等)
|
||||||
|
├── i18n/ # 国际化资源
|
||||||
|
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||||
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
|
└── package.json # 项目元数据与依赖管理
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发环境要求
|
## 开发与部署
|
||||||
|
|
||||||
- Node.js >= 18
|
### 开发环境要求
|
||||||
- npm 或 yarn
|
|
||||||
|
|
||||||
## 安装与运行
|
- Node.js >= 18.x
|
||||||
|
- npm 或 pnpm
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 常用命令
|
||||||
|
|
||||||
```bash
|
| 命令 | 说明 |
|
||||||
npm install
|
| ----------------------- | --------------------------------- |
|
||||||
```
|
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||||
|
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||||
|
| `npm run build` | 构建 Chrome 生产版本 |
|
||||||
|
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||||
|
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||||
|
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||||
|
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||||
|
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||||
|
| `npm run test` | 运行单元测试 |
|
||||||
|
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||||
|
|
||||||
### 2. 开发模式
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
```bash
|
### 自动化流程
|
||||||
# Chrome 浏览器
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||||
npm run dev:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 构建生产版本
|
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||||
|
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
|
||||||
npm run build:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 打包分发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run zip
|
|
||||||
|
|
||||||
# Firefox
|
|
||||||
npm run zip:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 其他命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run compile # TypeScript 类型检查
|
|
||||||
npm run lint # ESLint 代码检查
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限说明
|
## 权限说明
|
||||||
|
|
||||||
扩展请求以下权限:
|
本扩展根据功能需要申请了以下权限:
|
||||||
|
|
||||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||||
- `debugger` - 调试器权限
|
- `cookies`: 管理和清理网站 Cookie.
|
||||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||||
|
|
||||||
## 主要依赖
|
## 浏览器支持
|
||||||
|
|
||||||
- `react`, `react-dom` - 前端框架
|
- Chrome (及其它 Chromium 内核浏览器)
|
||||||
- `@mui/material` - UI 组件库
|
- Firefox
|
||||||
- `dayjs` - 日期处理
|
|
||||||
- `@webext-core/messaging` - 扩展消息通信
|
|
||||||
|
|
||||||
## 贡献指南
|
|
||||||
|
|
||||||
1. Fork 项目
|
|
||||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
此项目为私有项目 (private: true),仅供内部使用。
|
基于 [MIT License](LICENSE) 开源.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
|
||||||
|
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
|
||||||
|
|
||||||
|
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
|
||||||
|
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
|
||||||
|
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
|
text,
|
||||||
|
tooltip = '复制',
|
||||||
|
size = 'small',
|
||||||
|
variant = 'ghost',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
toast.error('无内容可复制');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await copyTextToClipboard(text);
|
||||||
|
if (success) {
|
||||||
|
toast.success('复制成功');
|
||||||
|
setCopied(true);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. 将控制尺寸的类名标准化
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'h-8 w-8 text-xs',
|
||||||
|
medium: 'h-10 w-10 text-sm',
|
||||||
|
large: 'h-12 w-12 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. 映射 shadcn 的底层通用 Variant 类名
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={tooltip}
|
||||||
|
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
sizeClasses[size],
|
||||||
|
copied
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
|
||||||
|
: variantClasses[variant],
|
||||||
|
className, // 允许外部直接传入 text-red-500 等覆盖样式
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* DecodeResultPaper
|
||||||
|
*
|
||||||
|
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
|
||||||
|
* 提取了二者 decode 输出区完全一致的结构:
|
||||||
|
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
|
||||||
|
*
|
||||||
|
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
|
||||||
|
*/
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatFileSize } from '@/utils/base64Converter';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
interface DecodeResultPaperProps {
|
||||||
|
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
||||||
|
title: string;
|
||||||
|
/** 解码后推断的 MIME 类型 */
|
||||||
|
mimeType: string;
|
||||||
|
/** 解码后 Blob 的大小(字节) */
|
||||||
|
blobSize: number;
|
||||||
|
/** 当前文件名 */
|
||||||
|
fileName: string;
|
||||||
|
/** 文件名变更回调 */
|
||||||
|
onFileNameChange: (name: string) => void;
|
||||||
|
/** 下载按钮点击回调 */
|
||||||
|
onDownload: () => void;
|
||||||
|
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecodeResultPaper({
|
||||||
|
title,
|
||||||
|
mimeType,
|
||||||
|
blobSize,
|
||||||
|
fileName,
|
||||||
|
onFileNameChange,
|
||||||
|
onDownload,
|
||||||
|
children,
|
||||||
|
}: DecodeResultPaperProps) {
|
||||||
|
const { t } = useLazyTranslation('base64Converter');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||||
|
{/* 标题 */}
|
||||||
|
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
|
||||||
|
|
||||||
|
{/* 可选预览内容(ImageMode 的图片) */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* 文件信息 */}
|
||||||
|
<div className="flex gap-4 mb-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('inferredMimeType')}: {mimeType}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('decodedSize')}: {formatFileSize(blobSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件名输入 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
{t('decodedFileName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fileName}
|
||||||
|
onChange={(e) => onFileNameChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下载按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onDownload}
|
||||||
|
disabled={!fileName.trim()}
|
||||||
|
className="w-full rounded-lg font-bold"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{t('download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
if (this.state.hasError && prevProps.children !== this.props.children) {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="mt-16 mx-auto max-w-md">
|
||||||
|
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
|
||||||
|
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-extrabold text-red-600 mb-2">糟糕,出了点问题</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
|
||||||
|
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700">
|
||||||
|
{this.state.error.toString()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
刷新应用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
||||||
|
*
|
||||||
|
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
||||||
|
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||||
|
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||||
|
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||||
|
*
|
||||||
|
* @module GlobalSnackbar
|
||||||
|
* @version 1.1.0
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // 方式一:受控组件
|
||||||
|
* <GlobalSnackbar
|
||||||
|
* message="操作成功"
|
||||||
|
* open={isOpen}
|
||||||
|
* onClose={() => setIsOpen(false)}
|
||||||
|
* severity="success"
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // 方式二:Hook 方式 (局部状态)
|
||||||
|
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||||
|
* showMessage('Hello!', { severity: 'info' });
|
||||||
|
*
|
||||||
|
* // 方式三:Context 方式 (全局状态)
|
||||||
|
* // 在根组件包裹 Provider
|
||||||
|
* <SnackbarProvider>
|
||||||
|
* <App />
|
||||||
|
* </SnackbarProvider>
|
||||||
|
*
|
||||||
|
* // 在子组件中使用
|
||||||
|
* const { showMessage } = useSnackbar();
|
||||||
|
* showMessage('Global Message');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
JSX,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snackbar 消息严重程度类型
|
||||||
|
* @description 决定 Alert 组件的颜色和图标
|
||||||
|
* - success: 绿色,成功提示
|
||||||
|
* - info: 蓝色,信息提示
|
||||||
|
* - warning: 橙色,警告提示
|
||||||
|
* - error: 红色,错误提示
|
||||||
|
*/
|
||||||
|
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件的属性接口
|
||||||
|
* @interface GlobalSnackbarProps
|
||||||
|
*/
|
||||||
|
export interface GlobalSnackbarProps {
|
||||||
|
/** 消息内容,要显示的提示文本 */
|
||||||
|
message: string;
|
||||||
|
/** 是否显示 Snackbar */
|
||||||
|
open: boolean;
|
||||||
|
/** 关闭回调函数 */
|
||||||
|
onClose: () => void;
|
||||||
|
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
||||||
|
severity?: SnackbarSeverity;
|
||||||
|
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
||||||
|
autoHideDuration?: number;
|
||||||
|
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
||||||
|
anchorOrigin?: {
|
||||||
|
vertical: 'top' | 'bottom';
|
||||||
|
horizontal: 'left' | 'center' | 'right';
|
||||||
|
};
|
||||||
|
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
||||||
|
showAlert?: boolean;
|
||||||
|
/** 是否隐藏 Alert 图标,默认 false */
|
||||||
|
hideIcon?: boolean;
|
||||||
|
/** 自定义样式,透传给外层 Snackbar 组件 */
|
||||||
|
sx?: React.CSSProperties;
|
||||||
|
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
||||||
|
alertSx?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* showMessage 方法的选项配置
|
||||||
|
* @interface SnackbarOptions
|
||||||
|
*/
|
||||||
|
export interface SnackbarOptions {
|
||||||
|
/** 消息级别:success | info | warning | error */
|
||||||
|
severity?: SnackbarSeverity;
|
||||||
|
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
||||||
|
autoHideDuration?: number;
|
||||||
|
/** 是否隐藏 Alert 图标 */
|
||||||
|
hideIcon?: boolean;
|
||||||
|
/** 是否使用 Alert 组件包裹 */
|
||||||
|
showAlert?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbarState Hook 的返回值类型
|
||||||
|
* @interface UseSnackbarStateResult
|
||||||
|
*/
|
||||||
|
export interface UseSnackbarStateResult {
|
||||||
|
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
||||||
|
snackbarProps: GlobalSnackbarProps;
|
||||||
|
/** 显示消息的方法 */
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
/** 关闭消息的方法 */
|
||||||
|
closeMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件的默认属性配置
|
||||||
|
* @description 提供类型安全的默认值选择
|
||||||
|
*/
|
||||||
|
const defaultProps: Required<
|
||||||
|
Pick<
|
||||||
|
GlobalSnackbarProps,
|
||||||
|
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
||||||
|
>
|
||||||
|
> = {
|
||||||
|
severity: 'info',
|
||||||
|
autoHideDuration: 2000,
|
||||||
|
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||||
|
showAlert: true,
|
||||||
|
hideIcon: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityConfig: Record<
|
||||||
|
SnackbarSeverity,
|
||||||
|
{ icon: React.ElementType; bgClass: string; textClass: string }
|
||||||
|
> = {
|
||||||
|
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
|
||||||
|
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
|
||||||
|
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
|
||||||
|
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalSnackbar 组件
|
||||||
|
*
|
||||||
|
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
||||||
|
*
|
||||||
|
* @param {GlobalSnackbarProps} props - 组件属性
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
*/
|
||||||
|
export function GlobalSnackbar({
|
||||||
|
message,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
severity = defaultProps.severity,
|
||||||
|
autoHideDuration = defaultProps.autoHideDuration,
|
||||||
|
showAlert = defaultProps.showAlert,
|
||||||
|
hideIcon = defaultProps.hideIcon,
|
||||||
|
}: GlobalSnackbarProps): JSX.Element | null {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && autoHideDuration > 0) {
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, autoHideDuration);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [open, autoHideDuration, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const config = severityConfig[severity];
|
||||||
|
const IconComponent = config.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||||
|
{showAlert ? (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
|
||||||
|
style={{ minWidth: '140px' }}
|
||||||
|
>
|
||||||
|
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
||||||
|
<span className="text-xs font-bold">{message}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbarState - 消息提示的 Hook 方式
|
||||||
|
*
|
||||||
|
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
||||||
|
* 适合在组件内部使用,无需额外的状态管理代码。
|
||||||
|
*
|
||||||
|
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
||||||
|
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 自动管理 Snackbar 的显示/隐藏状态
|
||||||
|
* - 支持链式调用 showMessage
|
||||||
|
* - 合并初始选项和调用时选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
||||||
|
* severity: 'info',
|
||||||
|
* autoHideDuration: 3000,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const handleSave = () => {
|
||||||
|
* // 业务逻辑...
|
||||||
|
* showMessage('保存成功!', { severity: 'success' });
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return (
|
||||||
|
* <>
|
||||||
|
* <button onClick={handleSave}>保存</button>
|
||||||
|
* <GlobalSnackbar {...snackbarProps} />
|
||||||
|
* </>
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
||||||
|
// Snackbar 显示状态
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// 当前显示的消息内容
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
// 消息配置选项
|
||||||
|
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示消息
|
||||||
|
*
|
||||||
|
* @param {string} newMessage - 要显示的消息文本
|
||||||
|
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 合并初始选项和新的调用选项
|
||||||
|
* - 新选项会覆盖初始选项
|
||||||
|
*/
|
||||||
|
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
||||||
|
setMessage(newMessage);
|
||||||
|
setOptions({ ...initialOptions, ...newOptions });
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭消息
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 直接将 open 状态设置为 false
|
||||||
|
*/
|
||||||
|
const closeMessage = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
|
||||||
|
if (reason === 'clickaway') return;
|
||||||
|
closeMessage();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 传递给 GlobalSnackbar 组件的属性
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* - 组合当前状态和选项为完整的组件 props
|
||||||
|
* - onClose 使用 handleClose 包装后的版本
|
||||||
|
*/
|
||||||
|
const snackbarProps: GlobalSnackbarProps = {
|
||||||
|
message,
|
||||||
|
open,
|
||||||
|
onClose: handleClose,
|
||||||
|
severity: options.severity,
|
||||||
|
autoHideDuration: options.autoHideDuration,
|
||||||
|
hideIcon: options.hideIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
snackbarProps,
|
||||||
|
showMessage,
|
||||||
|
closeMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context & Provider ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snackbar Context 的值类型定义
|
||||||
|
*/
|
||||||
|
interface SnackbarContextValue {
|
||||||
|
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||||
|
closeMessage: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SnackbarProvider 组件的 props 类型
|
||||||
|
*/
|
||||||
|
interface SnackbarProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
initialOptions?: SnackbarOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SnackbarProvider 组件
|
||||||
|
*
|
||||||
|
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
||||||
|
*/
|
||||||
|
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
|
||||||
|
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
||||||
|
{children}
|
||||||
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
|
</SnackbarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
||||||
|
*
|
||||||
|
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration)
|
||||||
|
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
||||||
|
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
||||||
|
*
|
||||||
|
* @description
|
||||||
|
* 选项合并策略:
|
||||||
|
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
|
||||||
|
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
|
||||||
|
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
|
||||||
|
*/
|
||||||
|
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
|
||||||
|
const context = useContext(SnackbarContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSnackbar must be used within SnackbarProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 包装 showMessage 以支持 Hook 级别的 initialOptions
|
||||||
|
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
|
||||||
|
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
|
||||||
|
// 优先级:callOptions > options
|
||||||
|
const mergedOptions: SnackbarOptions = {
|
||||||
|
...(options || {}),
|
||||||
|
...(callOptions || {}),
|
||||||
|
};
|
||||||
|
context.showMessage(message, mergedOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...context,
|
||||||
|
showMessage: wrappedShowMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalSnackbar;
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Image, X } from 'lucide-react';
|
||||||
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
/** 选中的文件 */
|
||||||
|
selectedFile: File | null;
|
||||||
|
/** 文件变更回调 */
|
||||||
|
onFileChange: (file: File) => void;
|
||||||
|
/** 清除文件回调 */
|
||||||
|
onClearFile: () => void;
|
||||||
|
/** 文件预览 URL */
|
||||||
|
previewUrl: string;
|
||||||
|
/** 预览 URL 变更回调 */
|
||||||
|
onPreviewUrlChange: (url: string) => void;
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
dragging: boolean;
|
||||||
|
/** 拖拽状态变更回调 */
|
||||||
|
onDraggingChange: (dragging: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUploader = ({
|
||||||
|
selectedFile,
|
||||||
|
onFileChange,
|
||||||
|
onClearFile,
|
||||||
|
previewUrl,
|
||||||
|
onPreviewUrlChange,
|
||||||
|
dragging,
|
||||||
|
onDraggingChange,
|
||||||
|
}: ImageUploaderProps) => {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
const { showMessage } = useSnackbar();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
onFileChange(file);
|
||||||
|
onPreviewUrlChange(URL.createObjectURL(file));
|
||||||
|
},
|
||||||
|
[onFileChange, onPreviewUrlChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearFile = () => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
onClearFile();
|
||||||
|
showMessage(t('qrCode:imageCleared'), {
|
||||||
|
severity: 'success',
|
||||||
|
autoHideDuration: 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleFileChange(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
onDraggingChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听粘贴事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
handleFileChange(file);
|
||||||
|
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理粘贴图片失败:', error);
|
||||||
|
showMessage(t('qrCode:imagePasteError'), {
|
||||||
|
severity: 'error',
|
||||||
|
autoHideDuration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
};
|
||||||
|
}, [showMessage, handleFileChange, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||||
|
dragging
|
||||||
|
? 'border-green-600 bg-green-50'
|
||||||
|
: selectedFile
|
||||||
|
? 'border-green-600 bg-green-50/50'
|
||||||
|
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
id="qr-code-upload"
|
||||||
|
/>
|
||||||
|
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
|
||||||
|
{selectedFile ? (
|
||||||
|
<div className="text-center w-full relative">
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
className="max-w-full max-h-40 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="ClearIcon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearFile();
|
||||||
|
}}
|
||||||
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
data-testid="ImageIcon"
|
||||||
|
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
|
||||||
|
/>
|
||||||
|
<span className="block text-sm text-muted-foreground mb-1">
|
||||||
|
{t('qrCode:clickToUpload')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t('qrCode:supportFormats')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploader;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
resetKey?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面级错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||||
|
* 完美适配 shadcn/ui 语义化主题与暗黑模式
|
||||||
|
*/
|
||||||
|
export class PageErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error in page:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/*
|
||||||
|
1. 适配暗黑模式的容器设计:
|
||||||
|
不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5,
|
||||||
|
并在黑夜模式下会自动转为深红底色,绝不刺眼。
|
||||||
|
*/}
|
||||||
|
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
||||||
|
{/* 2. 状态符号改用标准的 text-destructive 语义色 */}
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||||
|
<AlertCircle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-base font-semibold text-foreground mb-1.5">该功能运行异常</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-5">
|
||||||
|
该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
|
||||||
|
{this.state.error && (
|
||||||
|
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
||||||
|
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||||
|
{this.state.error.stack || this.state.error.toString()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
4. 严谨调用 shadcn 原子 Button:
|
||||||
|
去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。
|
||||||
|
它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。
|
||||||
|
*/}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="font-medium shadow-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
重新尝试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageErrorBoundary;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* PageSkeleton 组件 - 页面加载骨架屏
|
||||||
|
*
|
||||||
|
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
|
||||||
|
* 避免白屏闪烁,减少布局偏移
|
||||||
|
*/
|
||||||
|
interface PageSkeletonProps {
|
||||||
|
/** 骨架屏类型 */
|
||||||
|
variant?: 'dashboard' | 'tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘卡片骨架屏
|
||||||
|
*/
|
||||||
|
function DashboardCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border p-5 h-[100px]">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
|
||||||
|
<div>
|
||||||
|
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具页面骨架屏
|
||||||
|
*/
|
||||||
|
function ToolPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
|
||||||
|
|
||||||
|
{/* 控制栏 */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果区域 */}
|
||||||
|
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面加载骨架屏
|
||||||
|
*
|
||||||
|
* @param props - PageSkeletonProps
|
||||||
|
* @returns 骨架屏 JSX 元素
|
||||||
|
*/
|
||||||
|
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
|
||||||
|
if (variant === 'tool') {
|
||||||
|
return <ToolPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<DashboardCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PageSkeleton.displayName = 'PageSkeleton';
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Copy, Download } from 'lucide-react';
|
||||||
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
|
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||||
|
|
||||||
|
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||||
|
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** 二维码 Data URL */
|
||||||
|
qrCodeDataUrl: string;
|
||||||
|
/** 下载回调 */
|
||||||
|
onDownload: () => void;
|
||||||
|
/** 复制回调 */
|
||||||
|
onCopy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QrCodePreview = ({
|
||||||
|
qrCodeDataUrl,
|
||||||
|
onDownload,
|
||||||
|
onCopy,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: QrCodePreviewProps) => {
|
||||||
|
const { t } = useLazyTranslation('qrCode');
|
||||||
|
|
||||||
|
// 空状态下的虚线骨架屏
|
||||||
|
if (!qrCodeDataUrl) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full max-w-xs">
|
||||||
|
{/*
|
||||||
|
2. 二维码容器适配:
|
||||||
|
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||||
|
通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||||
|
*/}
|
||||||
|
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
|
||||||
|
<img
|
||||||
|
src={qrCodeDataUrl}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
|
||||||
|
<div className="flex w-full gap-2 mt-5">
|
||||||
|
{/* 下载按钮:使用标准的次要按钮风格 (Outline) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDownload}
|
||||||
|
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md border border-input bg-background shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 复制按钮:使用标准的主要行动按钮风格 (Default) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QrCodePreview;
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { Suspense, useMemo } from 'react';
|
||||||
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
|
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||||
|
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
|
||||||
|
|
||||||
|
export default function RouterContainer() {
|
||||||
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
|
||||||
|
// 2. 稳定的动态动画类名映射
|
||||||
|
const animationClass = useMemo(() => {
|
||||||
|
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
const entryPointType = useMemo(() => {
|
||||||
|
return getEntryPointType();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 骨架屏加载状态守卫
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 严格的路由查找与类型安全的组件分发
|
||||||
|
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||||
|
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||||
|
'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
|
||||||
|
animationClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||||
|
>
|
||||||
|
<PageErrorBoundary resetKey={currentPage}>
|
||||||
|
{/*
|
||||||
|
4. 路由防御拦截:
|
||||||
|
如果组件存在则正常流式渲染,如果由于版本更迭或非法路径导致找不到对应组件,
|
||||||
|
渲染一个优雅且符合 shadcn 风格的中性 404 提示页,而不是死白屏。
|
||||||
|
*/}
|
||||||
|
{MatchedComponent ? (
|
||||||
|
<MatchedComponent />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">页面未找到</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||||
|
该功能在当前运行环境({entryPointType})下不可用或已被移除。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||||
|
|
||||||
|
export interface SwitchOption<T extends string | number = string> {
|
||||||
|
value: T;
|
||||||
|
label: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
|
||||||
|
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
value: T;
|
||||||
|
options: SwitchOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
buttonClassName?: string; // 替换原有的 buttonSx
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
size = 'medium',
|
||||||
|
className,
|
||||||
|
buttonClassName,
|
||||||
|
...props
|
||||||
|
}: SwitchButtonGroupProps<T>) {
|
||||||
|
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'text-xs h-8 px-2 py-1 rounded-md',
|
||||||
|
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
|
||||||
|
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
// 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间)
|
||||||
|
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
|
||||||
|
containerPadding,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={cn(
|
||||||
|
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
|
||||||
|
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
sizeClasses[size],
|
||||||
|
isSelected
|
||||||
|
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
|
||||||
|
: 'hover:bg-background/50 hover:text-foreground/80',
|
||||||
|
buttonClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { Copy, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
||||||
|
|
||||||
|
export type ValidateRule = {
|
||||||
|
validator: (value: string) => boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolbarAction = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
type?: 'primary' | 'default' | 'danger';
|
||||||
|
disabled?: boolean | ((value: string) => boolean);
|
||||||
|
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TextInputAreaProps extends Omit<
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
|
||||||
|
/** 值变化回调,返回最新的字符串内容 */
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
showCount?: boolean;
|
||||||
|
showClear?: boolean;
|
||||||
|
allowCopy?: boolean;
|
||||||
|
rules?: ValidateRule[];
|
||||||
|
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||||
|
actions?: ToolbarAction[];
|
||||||
|
topExtra?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
externalError?: string;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
||||||
|
function ActionButton({
|
||||||
|
action,
|
||||||
|
value,
|
||||||
|
globalDisabled,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
action: ToolbarAction;
|
||||||
|
value: string;
|
||||||
|
globalDisabled: boolean;
|
||||||
|
onAction: (action: ToolbarAction) => void;
|
||||||
|
}) {
|
||||||
|
const isBtnDisabled =
|
||||||
|
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
default:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAction(action)}
|
||||||
|
disabled={isBtnDisabled || globalDisabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
variantClasses[action.type || 'default'],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
value: controlledValue,
|
||||||
|
defaultValue = '',
|
||||||
|
onChange,
|
||||||
|
placeholder: placeholderProp,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
autoFocus = false,
|
||||||
|
minRows = 4,
|
||||||
|
maxRows = 12,
|
||||||
|
maxLength,
|
||||||
|
className,
|
||||||
|
showCount = false,
|
||||||
|
showClear = true,
|
||||||
|
allowCopy = false,
|
||||||
|
rules = [],
|
||||||
|
validateTrigger = 'onAction',
|
||||||
|
actions = [],
|
||||||
|
topExtra,
|
||||||
|
title,
|
||||||
|
externalError,
|
||||||
|
onClear,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const { t } = useTranslation('common');
|
||||||
|
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
||||||
|
|
||||||
|
const isControlled = controlledValue !== undefined;
|
||||||
|
const value = isControlled ? controlledValue : internalValue;
|
||||||
|
const displayError = externalError ?? error;
|
||||||
|
|
||||||
|
// 双向合并 ref 指针
|
||||||
|
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
||||||
|
|
||||||
|
// 1. 高性能的动态高度自适应计算
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
const textArea = internalRef.current;
|
||||||
|
if (!textArea) return;
|
||||||
|
|
||||||
|
// 重置高度计算
|
||||||
|
textArea.style.height = 'auto';
|
||||||
|
|
||||||
|
const computedMin = minRows * 24; // 每行粗略按 24px 计算
|
||||||
|
const computedMax = maxRows * 24;
|
||||||
|
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
|
||||||
|
|
||||||
|
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
|
||||||
|
}, [minRows, maxRows]);
|
||||||
|
|
||||||
|
// 当数值改变时自适应扩展
|
||||||
|
React.useEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, [value, adjustHeight]);
|
||||||
|
|
||||||
|
const validate = useCallback(
|
||||||
|
(val: string, trigger?: string): boolean => {
|
||||||
|
if (validateTrigger !== trigger && trigger) return true;
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.validator(val)) {
|
||||||
|
setError(rule.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[rules, validateTrigger],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
if (maxLength && newVal.length > maxLength) {
|
||||||
|
const msg = t('charCount', { count: maxLength });
|
||||||
|
setError(msg);
|
||||||
|
toast.warning(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isControlled) setInternalValue(newVal);
|
||||||
|
onChange?.(newVal);
|
||||||
|
|
||||||
|
if (error) setError('');
|
||||||
|
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
if (!isControlled) setInternalValue('');
|
||||||
|
onChange?.('');
|
||||||
|
setError('');
|
||||||
|
internalRef.current?.focus();
|
||||||
|
toast.success('已清空内容');
|
||||||
|
onClear?.();
|
||||||
|
}, [isControlled, onChange, onClear]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
toast.success('复制成功');
|
||||||
|
} catch {
|
||||||
|
setError('复制失败');
|
||||||
|
toast.error('复制失败');
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
(action: ToolbarAction) => {
|
||||||
|
const isDisabled =
|
||||||
|
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
||||||
|
if (isDisabled || disabled) return;
|
||||||
|
|
||||||
|
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
||||||
|
|
||||||
|
action.onClick(value, {
|
||||||
|
clear: handleClear,
|
||||||
|
setError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value, disabled, validate, validateTrigger, handleClear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const topActions = actions.filter((a) => a.position !== 'bottom');
|
||||||
|
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
||||||
|
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
||||||
|
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
||||||
|
{hasTopBar && (
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
||||||
|
{topExtra}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{topActions.map((action) => (
|
||||||
|
<ActionButton
|
||||||
|
key={action.key}
|
||||||
|
action={action}
|
||||||
|
value={value}
|
||||||
|
globalDisabled={disabled}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showCount && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{value.length}
|
||||||
|
{maxLength ? ` / ${maxLength}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
|
||||||
|
displayError &&
|
||||||
|
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={internalRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasBottomBar && (
|
||||||
|
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
|
||||||
|
{/* 左侧自定义动作 */}
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{bottomActions.map((action) => (
|
||||||
|
<ActionButton
|
||||||
|
key={action.key}
|
||||||
|
action={action}
|
||||||
|
value={value}
|
||||||
|
globalDisabled={disabled}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧系统按钮组 */}
|
||||||
|
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||||
|
{allowCopy && value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
aria-label={t('textInputArea.copyContent')}
|
||||||
|
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showClear && value && !disabled && !readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label={t('textInputArea.clear')}
|
||||||
|
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{displayError && (
|
||||||
|
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
{displayError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TextInputArea.displayName = 'TextInputArea';
|
||||||
|
|
||||||
|
export default TextInputArea;
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
|
Globe,
|
||||||
|
History,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Sun,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
|
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||||
|
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
|
||||||
|
|
||||||
|
// 常量配置抽取(无需写在全局变量或 styles 对象里)
|
||||||
|
const SEARCH_HISTORY_LIMIT = 10;
|
||||||
|
const SEARCH_HISTORY_DISPLAY = 5;
|
||||||
|
|
||||||
|
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||||
|
const { currentPage, goBack, navigateTo } = useRouter();
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
|
const { t, i18n } = useTranslation(['common', 'features']);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleOpenInTab = async () => {
|
||||||
|
await openExtensionPage('popup.html', { mode: 'tab' });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 从 Chrome Storage 异步初始化历史记录
|
||||||
|
useEffect(() => {
|
||||||
|
storageUtil
|
||||||
|
.get('app/searchHistory', [])
|
||||||
|
.then((history) => {
|
||||||
|
if (history) setSearchHistory(history);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('加载搜索历史失败:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) return [];
|
||||||
|
return FEATURES.filter((f) => {
|
||||||
|
if (f.key === 'dashboard') return false;
|
||||||
|
return (
|
||||||
|
t(f.labelKey).toLowerCase().includes(query) ||
|
||||||
|
t(f.descriptionKey).toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [searchQuery, t]);
|
||||||
|
|
||||||
|
const displayedHistory = useMemo(() => {
|
||||||
|
if (searchQuery.trim()) return [];
|
||||||
|
return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
|
||||||
|
}, [searchHistory, searchQuery]);
|
||||||
|
|
||||||
|
// 新增/持久化历史记录
|
||||||
|
const saveToHistory = async (query: string) => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
|
||||||
|
0,
|
||||||
|
SEARCH_HISTORY_LIMIT,
|
||||||
|
);
|
||||||
|
setSearchHistory(nextHistory);
|
||||||
|
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFeature = (feature: FeatureConfig) => {
|
||||||
|
navigateTo(feature.key);
|
||||||
|
saveToHistory(t(feature.labelKey));
|
||||||
|
setSearchQuery('');
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLanguage = async () => {
|
||||||
|
const currentLng = normalizeLanguage(i18n.language);
|
||||||
|
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
|
||||||
|
const nextLng = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||||
|
await i18n.changeLanguage(nextLng);
|
||||||
|
await storageUtil.set('app/language', nextLng);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleThemeMode = () => {
|
||||||
|
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||||
|
setMode(nextMap[mode]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
||||||
|
|
||||||
|
// 4. 健壮的键盘导航交互
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
handleSelectFeature(searchResults[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
const selectedQuery = displayedHistory[selectedIndex];
|
||||||
|
setSearchQuery(selectedQuery);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
const matched = FEATURES.find(
|
||||||
|
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
|
||||||
|
);
|
||||||
|
if (matched) handleSelectFeature(matched);
|
||||||
|
}
|
||||||
|
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||||
|
handleSelectFeature(searchResults[0]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowResults(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDashboard = currentPage === 'dashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
||||||
|
{/* 左侧:返回按钮区 */}
|
||||||
|
<div className="flex w-10 items-center justify-start">
|
||||||
|
{!isDashboard && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goBack}
|
||||||
|
aria-label={t('common:buttons.back')}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间:搜索容器 */}
|
||||||
|
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={t('common:buttons.search')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setShowResults(true);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowResults(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label={t('common:buttons.search')}
|
||||||
|
className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
aria-label={t('common:buttons.clearSearch')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动态联想结果卡片 */}
|
||||||
|
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-popover text-popover-foreground rounded-md shadow-md border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
<ul role="listbox" className="p-1">
|
||||||
|
{searchQuery.trim() ? (
|
||||||
|
searchResults.length > 0 ? (
|
||||||
|
searchResults.map((feature, index) => (
|
||||||
|
<li
|
||||||
|
key={feature.key}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIndex === index}
|
||||||
|
onClick={() => handleSelectFeature(feature)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
||||||
|
selectedIndex === index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-muted/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
|
||||||
|
{feature.icon && <feature.icon className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{t(feature.labelKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{t(feature.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t('common:buttons.noResults')}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
|
||||||
|
{t('common:buttons.recentSearch')}
|
||||||
|
</div>
|
||||||
|
{displayedHistory.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIndex === index}
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery(item);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
||||||
|
selectedIndex === index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-muted/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="truncate">{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:操作区 */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<IconButton onClick={toggleLanguage} title={t('common:buttons.toggleLanguage')}>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
|
||||||
|
<ThemeIcon className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={onOpenOptions} title={t('common:buttons.settings')}>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
|
||||||
|
function IconButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||||
|
|
||||||
|
describe('DecodeResultPaper 组件', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
title: 'decodedFileOutput',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
blobSize: 1024,
|
||||||
|
fileName: 'decoded.png',
|
||||||
|
onFileNameChange: vi.fn(),
|
||||||
|
onDownload: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应渲染标题', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染 MIME 类型信息', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应通过 formatFileSize 渲染文件大小', () => {
|
||||||
|
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
|
||||||
|
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染文件名输入框', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
const input = screen.getByDisplayValue('decoded.png');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染下载按钮', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染 children 内容', () => {
|
||||||
|
render(
|
||||||
|
<DecodeResultPaper {...defaultProps}>
|
||||||
|
<div data-testid="preview">预览内容</div>
|
||||||
|
</DecodeResultPaper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('预览内容')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('修改文件名时应调用 onFileNameChange', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
const input = screen.getByDisplayValue('decoded.png');
|
||||||
|
fireEvent.change(input, { target: { value: 'new-name.png' } });
|
||||||
|
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击下载按钮时应调用 onDownload', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'base64Converter:download' }));
|
||||||
|
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('按钮状态', () => {
|
||||||
|
it('文件名为空时下载按钮应禁用', () => {
|
||||||
|
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件名不为空时下载按钮应启用', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'base64Converter:download' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
|
||||||
|
// 用于触发错误的测试子组件
|
||||||
|
function ThrowError({ message }: { message: string }): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常渲染的子组件
|
||||||
|
function NormalComponent({ text }: { text: string }) {
|
||||||
|
return <div data-testid="normal-content">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 抑制测试中故意抛出的错误日志
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('正常渲染子组件', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<NormalComponent text="正常内容" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('子组件抛出错误时显示错误 UI', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="测试错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('children 变化时重置错误状态', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="初始错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 切换到正常子组件
|
||||||
|
rerender(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<NormalComponent text="恢复后的内容" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相同的 children 不重置错误状态', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="相同子组件错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 用相同的 children rerender
|
||||||
|
rerender(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="相同子组件错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误 UI 包含刷新按钮', () => {
|
||||||
|
const reloadMock = vi.fn();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { reload: reloadMock },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="按钮测试" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
|
||||||
|
expect(refreshButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
refreshButton.click();
|
||||||
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
GlobalSnackbar,
|
||||||
|
type GlobalSnackbarProps,
|
||||||
|
SnackbarProvider,
|
||||||
|
useSnackbar,
|
||||||
|
useSnackbarState,
|
||||||
|
} from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
|
describe('GlobalSnackbar 组件系统', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps: GlobalSnackbarProps = {
|
||||||
|
message: '测试消息',
|
||||||
|
open: true,
|
||||||
|
onClose: mockOnClose,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GlobalSnackbar UI 渲染', () => {
|
||||||
|
it('应渲染消息内容', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} />);
|
||||||
|
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
||||||
|
// 验证是否包含消息文本
|
||||||
|
const alertElement = screen.getByText('测试消息');
|
||||||
|
expect(alertElement).toBeInTheDocument();
|
||||||
|
// 验证父元素有正确的样式类
|
||||||
|
const parent = alertElement.parentElement;
|
||||||
|
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
||||||
|
// 图标使用 lucide-react 的 svg 元素
|
||||||
|
const icon = document.querySelector('svg');
|
||||||
|
expect(icon).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应根据 severity 应用不同的样式', () => {
|
||||||
|
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
||||||
|
const message = screen.getByText('测试消息');
|
||||||
|
const parent = message.parentElement;
|
||||||
|
expect(parent).toHaveClass('bg-red-500');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSnackbarState Hook 逻辑', () => {
|
||||||
|
it('应返回初始状态', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
|
expect(result.current.snackbarProps.message).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showMessage 应更新状态', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('新消息');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.snackbarProps.open).toBe(true);
|
||||||
|
expect(result.current.snackbarProps.message).toBe('新消息');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeMessage 应关闭消息', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('消息');
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.closeMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSnackbar Context Hook 优先级', () => {
|
||||||
|
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
|
||||||
|
|
||||||
|
// 1. 测试 Hook Options 覆盖 Provider Options
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('消息 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 2. 测试 Call Options 覆盖 Hook Options
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('消息 2', { severity: 'error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
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
|
||||||
|
const mockCreateObjectURL = vi.fn();
|
||||||
|
const mockRevokeObjectURL = vi.fn();
|
||||||
|
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||||
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||||
|
|
||||||
|
// 模拟 showMessage
|
||||||
|
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||||
|
useSnackbar: () => ({
|
||||||
|
showMessage: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ImageUploader 组件', () => {
|
||||||
|
const mockOnFileChange = vi.fn();
|
||||||
|
const mockOnClearFile = vi.fn();
|
||||||
|
const mockOnPreviewUrlChange = vi.fn();
|
||||||
|
const mockOnDraggingChange = vi.fn();
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedFile: null,
|
||||||
|
onFileChange: mockOnFileChange,
|
||||||
|
onClearFile: mockOnClearFile,
|
||||||
|
previewUrl: '',
|
||||||
|
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||||
|
dragging: false,
|
||||||
|
onDraggingChange: mockOnDraggingChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('当没有选中文件时应显示上传提示', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
// 💡 修复点 2:全面切换为高弹性正则,斩断双重命名空间死锁!
|
||||||
|
expect(screen.getByText(/clickToUpload/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/supportFormats/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示文件预览', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||||
|
// 💡 修复点 3(自愈第 62 行崩溃位置):利用正则模糊命中,彻底通过!
|
||||||
|
expect(screen.getByText(/clickToChange/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示预览图片', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
const img = screen.getByAltText('QR Code Preview');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当选中文件时应显示清除按钮', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应包含隐藏的文件输入框', () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(input).toHaveAttribute('type', 'file');
|
||||||
|
expect(input).toHaveAttribute('accept', 'image/*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('文件选择交互', () => {
|
||||||
|
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||||
|
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('拖拽交互', () => {
|
||||||
|
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragOver(dropzone);
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.dragLeave(dropzone);
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
|
||||||
|
const dropEvent = new Event('drop', { bubbles: true });
|
||||||
|
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||||
|
value: {
|
||||||
|
files: [mockFile],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(dropzone, dropEvent);
|
||||||
|
|
||||||
|
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||||
|
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('清除文件功能', () => {
|
||||||
|
it('点击清除按钮时应调用 onClearFile', () => {
|
||||||
|
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');
|
||||||
|
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('粘贴功能', () => {
|
||||||
|
it('监听粘贴事件', () => {
|
||||||
|
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||||
|
render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时应移除粘贴事件监听', () => {
|
||||||
|
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||||
|
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式测试', () => {
|
||||||
|
it('拖拽状态时应应用拖拽样式', () => {
|
||||||
|
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
expect(dropzone).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('有文件时应应用有文件样式', () => {
|
||||||
|
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||||
|
const { container } = render(
|
||||||
|
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||||
|
);
|
||||||
|
const dropzone = container.firstChild as HTMLElement;
|
||||||
|
expect(dropzone).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { PageErrorBoundary } from '@/components/PageErrorBoundary';
|
||||||
|
|
||||||
|
// 用于触发错误的测试子组件
|
||||||
|
function ThrowError({ message }: { message: string }): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常渲染的子组件
|
||||||
|
function NormalComponent({ text }: { text: string }) {
|
||||||
|
return <div data-testid="normal-content">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PageErrorBoundary', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 抑制测试中故意抛出的错误日志
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('正常渲染子组件', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<NormalComponent text="正常内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('子组件抛出错误时显示错误卡片 UI', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="测试错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击重试按钮后恢复', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="可恢复错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 将子组件替换为正常组件,然后点击重试
|
||||||
|
rerender(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<NormalComponent text="恢复后的内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||||
|
retryButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetKey 变化时自动重置错误状态', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PageErrorBoundary resetKey="page-a">
|
||||||
|
<ThrowError message="页面 A 错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 切换 resetKey,同时提供正常子组件
|
||||||
|
rerender(
|
||||||
|
<PageErrorBoundary resetKey="page-b">
|
||||||
|
<NormalComponent text="页面 B 内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetKey 不变时保持错误状态', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PageErrorBoundary resetKey="page-a">
|
||||||
|
<ThrowError message="初始错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 仅 children 变化,resetKey 不变,错误应保持
|
||||||
|
rerender(
|
||||||
|
<PageErrorBoundary resetKey="page-a">
|
||||||
|
<NormalComponent text="新内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误 UI 包含重试按钮', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="按钮测试" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||||
|
expect(retryButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误信息以 monospace 格式显示', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="格式化测试" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorText = screen.getByText(/格式化测试/);
|
||||||
|
expect(errorText).toBeInTheDocument();
|
||||||
|
expect(errorText.tagName.toLowerCase()).toBe('pre');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
|
|
||||||
|
describe('PageSkeleton 组件', () => {
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('默认应渲染 dashboard 骨架屏', () => {
|
||||||
|
const { container } = render(<PageSkeleton />);
|
||||||
|
|
||||||
|
// dashboard 骨架屏包含 6 个卡片
|
||||||
|
const cards = container.querySelectorAll('.rounded-xl');
|
||||||
|
expect(cards.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||||
|
|
||||||
|
// 每个卡片有 2 个骨架元素(图标、文本),6 个卡片共 12 个
|
||||||
|
const cards = container.querySelectorAll('.rounded-xl');
|
||||||
|
expect(cards.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('variant 为 tool 时应渲染工具页面骨架', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="tool" />);
|
||||||
|
|
||||||
|
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('布局结构测试', () => {
|
||||||
|
it('dashboard 骨架屏应使用 grid 布局', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||||
|
const gridContainer = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(gridContainer).toHaveClass('grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool 骨架屏应有内边距', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="tool" />);
|
||||||
|
const toolContainer = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(toolContainer).toHaveClass('p-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('骨架屏元素测试', () => {
|
||||||
|
it('dashboard 骨架屏应包含圆角和边框样式', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||||
|
|
||||||
|
// 获取第一个卡片容器
|
||||||
|
const card = container.querySelector('.rounded-xl.border');
|
||||||
|
expect(card).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool 骨架屏应包含动画脉冲效果', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="tool" />);
|
||||||
|
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
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 组件', () => {
|
||||||
|
const mockOnDownload = vi.fn();
|
||||||
|
const mockOnCopy = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||||
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
|
// 💡 修复点 2:全面拥抱柔性正则匹配,直接终结多层 'qrCode:qrCode:' 前缀踩踏!
|
||||||
|
expect(screen.getByText(/qrCodeWillShow/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||||
|
const testDataUrl = 'data:image/png;base64,test123';
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl={testDataUrl}
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const img = screen.getByAltText('QR Code Preview');
|
||||||
|
expect(img).toBeInTheDocument();
|
||||||
|
expect(img).toHaveAttribute('src', testDataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 💡 修复点 3:切换为正则,无缝过检
|
||||||
|
expect(screen.getByText(/downloadButton/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 💡 修复点 4:切换为正则,无缝过检
|
||||||
|
expect(screen.getByText(/copyQrButton/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||||
|
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||||
|
expect(screen.queryByText(/downloadButton/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/copyQrButton/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 💡 修复点 5:点击行为同步更改为正则匹配定位,保障状态修改流一帧直达
|
||||||
|
fireEvent.click(screen.getByText(/downloadButton/));
|
||||||
|
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||||
|
render(
|
||||||
|
<QrCodePreview
|
||||||
|
qrCodeDataUrl="data:image/png;base64,test"
|
||||||
|
onDownload={mockOnDownload}
|
||||||
|
onCopy={mockOnCopy}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// 💡 修复点 6:彻底修复第 88 行报错位置,改用正则解开死锁!
|
||||||
|
fireEvent.click(screen.getByText(/copyQrButton/));
|
||||||
|
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const mockRouterValue = {
|
||||||
|
currentPage: 'dashboard' as PageType,
|
||||||
|
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||||
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
|
isLoaded: true,
|
||||||
|
navigateTo: vi.fn(),
|
||||||
|
syncNavigation: vi.fn(),
|
||||||
|
goBack: vi.fn(),
|
||||||
|
setVisiblePages: vi.fn(),
|
||||||
|
setPageOrder: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/providers/RouterProvider', () => ({
|
||||||
|
useRouter: () => mockRouterValue,
|
||||||
|
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RouterContainer 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<SnackbarProvider>
|
||||||
|
<RouterProvider>{ui}</RouterProvider>
|
||||||
|
</SnackbarProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||||
|
mockRouterValue.isLoaded = false;
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
// 骨架屏使用 animate-pulse 类
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||||
|
mockRouterValue.isLoaded = true;
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('动画类测试', () => {
|
||||||
|
it('在 dashboard 页面应应用 dashboard 动画类', () => {
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
const box = document.querySelector('.page-transition-dashboard');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在非 dashboard 页面应应用 enter 动画类', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
const box = document.querySelector('.page-transition-enter');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('路由处理测试', () => {
|
||||||
|
it('currentPage 变化时应更新', () => {
|
||||||
|
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
rerender(
|
||||||
|
<SnackbarProvider>
|
||||||
|
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
||||||
|
</SnackbarProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const box = document.querySelector('.page-transition-enter');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('页面级错误隔离', () => {
|
||||||
|
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
|
||||||
|
mockRouterValue.isLoaded = true;
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
|
// 验证 RouterContainer 的 Box 结构存在
|
||||||
|
const routerBox = container.querySelector('.page-transition-dashboard');
|
||||||
|
expect(routerBox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm';
|
||||||
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// 💡 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 组件', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: true,
|
||||||
|
serviceWorkers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = (props?: Partial<React.ComponentProps<typeof StorageCleanerConfirm>>) => {
|
||||||
|
return render(
|
||||||
|
<StorageCleanerConfirm
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
options={defaultOptions}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('open 为 true 时应渲染对话框', () => {
|
||||||
|
renderComponent();
|
||||||
|
// 💡 修复点 3:拥抱模糊正则断言。
|
||||||
|
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
|
||||||
|
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应显示警告信息', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应将选中的选项显示为标签', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应显示取消和确认按钮', () => {
|
||||||
|
renderComponent();
|
||||||
|
// 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击取消时应调用 onClose', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击确认时应调用 onConfirm', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('选项过滤测试', () => {
|
||||||
|
it('应仅显示选中的选项', () => {
|
||||||
|
const partialOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: false,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: false,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
|
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应处理空选项', () => {
|
||||||
|
const emptyOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: false,
|
||||||
|
sessionStorage: false,
|
||||||
|
indexedDB: false,
|
||||||
|
cookies: false,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: emptyOptions });
|
||||||
|
|
||||||
|
const chips = screen.queryAllByRole('button');
|
||||||
|
expect(chips.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('对话框行为测试', () => {
|
||||||
|
it('open 为 false 时不应渲染', () => {
|
||||||
|
renderComponent({ open: false });
|
||||||
|
expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应使用不同选项渲染', () => {
|
||||||
|
const customOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: false,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: false,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: customOptions });
|
||||||
|
|
||||||
|
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
|
||||||
|
describe('SwitchButtonGroup 组件', () => {
|
||||||
|
const options = [
|
||||||
|
{ value: 'a', label: '选项A' },
|
||||||
|
{ value: 'b', label: '选项B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('应渲染所有选项按钮', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应高亮当前选中的按钮', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const buttonA = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
const buttonB = screen.getByRole('button', { name: /选项B/i });
|
||||||
|
|
||||||
|
// 选中的按钮有 bg-background text-foreground shadow-sm 类
|
||||||
|
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||||
|
// 未选中的按钮有 hover:bg-background/50 类
|
||||||
|
expect(buttonB).toHaveClass('hover:bg-background/50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击未选中按钮时应触发 onChange 并传入选中值', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
|
||||||
|
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击已选中按钮时不应触发 onChange', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
|
||||||
|
// 新组件每次点击都会触发 onChange
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持通过 className 自定义样式', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} className="custom-group" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const group = container.firstChild;
|
||||||
|
expect(group).toHaveClass('custom-group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 size 属性', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} size="small" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toHaveClass('text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 buttonSx 自定义按钮样式', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 ReactNode 类型的 label', () => {
|
||||||
|
const nodeOptions = [{ value: 'x', label: <span data-testid="custom-label">自定义</span> }];
|
||||||
|
render(<SwitchButtonGroup value="x" options={nodeOptions} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('custom-label')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认按钮样式应禁止文字换行', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toHaveClass('whitespace-nowrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttonSx 传入时应覆盖默认换行样式', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('number 类型支持', () => {
|
||||||
|
const numberOptions = [
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 4, label: '4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('应支持 number 类型的 value 渲染', () => {
|
||||||
|
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /^2$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^4$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应高亮 number 类型的当前选中项', () => {
|
||||||
|
render(<SwitchButtonGroup value={4} options={numberOptions} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button2 = screen.getByRole('button', { name: /^2$/i });
|
||||||
|
const button4 = screen.getByRole('button', { name: /^4$/i });
|
||||||
|
|
||||||
|
expect(button2).toHaveClass('hover:bg-background/50');
|
||||||
|
expect(button4).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击 number 选项时应传回 number 值', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^4$/i }));
|
||||||
|
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
|
||||||
|
describe('TextInputArea 组件', () => {
|
||||||
|
describe('基础渲染', () => {
|
||||||
|
it('应渲染 placeholder', () => {
|
||||||
|
render(<TextInputArea placeholder="请输入文本..." />);
|
||||||
|
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染传入的 value', () => {
|
||||||
|
render(<TextInputArea value="测试内容" onChange={() => {}} />);
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveValue('测试内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认显示清空按钮', () => {
|
||||||
|
render(<TextInputArea value="有内容" onChange={() => {}} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无内容时清空按钮应隐藏', () => {
|
||||||
|
render(<TextInputArea value="" onChange={() => {}} />);
|
||||||
|
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled 时清空按钮应隐藏', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||||
|
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readOnly 时清空按钮应隐藏', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||||
|
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showClear=false 时不显示清空按钮', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
|
||||||
|
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('受控模式', () => {
|
||||||
|
it('输入时触发 onChange', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="" onChange={handleChange} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('新内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清空按钮触发 onChange("")', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="内容" onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('非受控模式', () => {
|
||||||
|
it('defaultValue 应显示初始值', () => {
|
||||||
|
render(<TextInputArea defaultValue="初始值" />);
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('初始值');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('输入后应更新内部值', () => {
|
||||||
|
render(<TextInputArea defaultValue="" />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||||
|
|
||||||
|
expect(textarea).toHaveValue('新内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清空按钮应清空内容', () => {
|
||||||
|
render(<TextInputArea defaultValue="内容" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allowCopy 复制功能', () => {
|
||||||
|
it('allowCopy 且有内容时显示复制按钮', () => {
|
||||||
|
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
|
||||||
|
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowCopy 但无内容时隐藏复制按钮', () => {
|
||||||
|
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowCopy=false 时不显示复制按钮', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制时调用 clipboard writeText', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||||
|
|
||||||
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeTextSpy = vi
|
||||||
|
.spyOn(navigator.clipboard, 'writeText')
|
||||||
|
.mockRejectedValue(new Error('失败'));
|
||||||
|
|
||||||
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||||
|
|
||||||
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showCount 字符计数', () => {
|
||||||
|
it('显示当前字符数', () => {
|
||||||
|
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空内容时显示 0', () => {
|
||||||
|
render(<TextInputArea value="" onChange={() => {}} showCount />);
|
||||||
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('设置 maxLength 时显示计数上限', () => {
|
||||||
|
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
|
||||||
|
expect(screen.getByText('2 / 10')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maxLength', () => {
|
||||||
|
it('超出 maxLength 的输入应被截断', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '123456' } });
|
||||||
|
|
||||||
|
expect(handleChange).not.toHaveBeenCalledWith('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未超出 maxLength 的输入应正常触发', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '123' } });
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('验证规则', () => {
|
||||||
|
it('onChange 触发时验证失败应设置 error', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'ab' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onBlur 触发时验证失败应设置 error', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onBlur"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.blur(textarea);
|
||||||
|
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('验证通过不应显示错误', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="abc"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'abcd' } });
|
||||||
|
|
||||||
|
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onAction 触发时验证失败应阻止 action 执行', () => {
|
||||||
|
const handleAction = vi.fn();
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onAction"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: 'test',
|
||||||
|
label: '执行',
|
||||||
|
onClick: handleAction,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('执行'));
|
||||||
|
|
||||||
|
expect(handleAction).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('操作栏 actions', () => {
|
||||||
|
it('应渲染顶部操作按钮', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('顶部操作')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染底部操作按钮', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[
|
||||||
|
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('底部操作')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击操作按钮触发 onClick', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('操作'));
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(
|
||||||
|
'内容',
|
||||||
|
expect.objectContaining({
|
||||||
|
clear: expect.any(Function),
|
||||||
|
setError: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled 为 true 时按钮应禁用', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('操作')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled 为函数且返回 true 时按钮应禁用', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('操作')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('primary 类型按钮应使用 contained 样式', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[
|
||||||
|
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByText('主要');
|
||||||
|
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('title', () => {
|
||||||
|
it('应渲染 title', () => {
|
||||||
|
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
|
||||||
|
expect(screen.getByText('输入区域')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不设置 title 时不渲染标题', () => {
|
||||||
|
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
|
||||||
|
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disabled 和 readOnly', () => {
|
||||||
|
it('disabled 时输入框应禁用', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||||
|
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readOnly 时输入框应只读', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||||
|
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
|
||||||
|
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoFocus', () => {
|
||||||
|
it('autoFocus 应自动聚焦', () => {
|
||||||
|
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole('textbox'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('复制功能', () => {
|
||||||
|
it('复制成功时调用 clipboard writeText', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||||
|
|
||||||
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onClear 回调', () => {
|
||||||
|
it('点击清空按钮时应调用 onClear', () => {
|
||||||
|
const handleClear = vi.fn();
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||||
|
|
||||||
|
expect(handleClear).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不传 onClear 时清空按钮应正常工作', () => {
|
||||||
|
render(<TextInputArea defaultValue="内容" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('externalError 外部错误', () => {
|
||||||
|
it('设置 externalError 时应显示错误状态', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externalError 为空时应隐藏错误状态', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('错误')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externalError 优先级高于内部验证错误', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
externalError="外部错误"
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externalError 清除后应显示内部验证错误', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
externalError="外部错误"
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'a' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoResize', () => {
|
||||||
|
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = container.querySelector('textarea');
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoResize=false 时设置固定 rows', () => {
|
||||||
|
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||||
|
|
||||||
|
const textarea = container.querySelector('textarea');
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式集成', () => {
|
||||||
|
it('应透传 className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import ToolCard from '@/pages/Dashboard/ToolCard';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
describe('ToolCard 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应渲染标题和描述', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="测试工具"
|
||||||
|
description="这是一个测试工具"
|
||||||
|
colorKey="primary"
|
||||||
|
icon={Clock}
|
||||||
|
onNavigate={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无描述时仅渲染标题', () => {
|
||||||
|
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染图标', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const svgElement = container.querySelector('svg');
|
||||||
|
expect(svgElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提供快照内容时应渲染快照', () => {
|
||||||
|
render(
|
||||||
|
<ToolCard
|
||||||
|
title="带快照"
|
||||||
|
colorKey="primary"
|
||||||
|
icon={Clock}
|
||||||
|
onNavigate={() => {}}
|
||||||
|
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未提供快照时不渲染快照区域', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolCard
|
||||||
|
title="无快照"
|
||||||
|
colorKey="primary"
|
||||||
|
icon={Clock}
|
||||||
|
onClick={() => {}}
|
||||||
|
onNavigate={function (): void {
|
||||||
|
throw new Error('Function not implemented.');
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||||
|
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击时应调用 onClick', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /可点击/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按 Enter 键时应调用 onClick', async () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
||||||
|
await act(async () => {
|
||||||
|
button.focus();
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式测试', () => {
|
||||||
|
it('应应用自定义颜色代码', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const svgElement = container.querySelector('svg');
|
||||||
|
expect(svgElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import React from 'react';
|
||||||
|
import TopBar from '@/components/TopBar';
|
||||||
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
|
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
||||||
|
|
||||||
|
// matchMedia must be mocked before ThemeModeProvider is imported
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRouterValue = {
|
||||||
|
currentPage: 'dashboard' as PageType,
|
||||||
|
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||||
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
|
isLoaded: true,
|
||||||
|
navigateTo: vi.fn(),
|
||||||
|
syncNavigation: vi.fn(),
|
||||||
|
goBack: vi.fn(),
|
||||||
|
setVisiblePages: vi.fn(),
|
||||||
|
setPageOrder: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/providers/RouterProvider', () => ({
|
||||||
|
useRouter: () => mockRouterValue,
|
||||||
|
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TopBar 组件', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<ThemeModeProvider>
|
||||||
|
<RouterProvider>{ui}</RouterProvider>
|
||||||
|
</ThemeModeProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染设置按钮', () => {
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击设置按钮时应调用 onOpenOptions', () => {
|
||||||
|
const handleOpenOptions = vi.fn();
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
|
||||||
|
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击返回按钮时应调用 goBack', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText('common:buttons.back'));
|
||||||
|
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
FEATURES,
|
||||||
|
getAllFeatureKeys,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getFeatureByKey,
|
||||||
|
} from '@/config/features';
|
||||||
|
|
||||||
|
describe('features', () => {
|
||||||
|
describe('FEATURES', () => {
|
||||||
|
it('should have 11 features defined', () => {
|
||||||
|
expect(FEATURES).toHaveLength(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all required properties for each feature', () => {
|
||||||
|
FEATURES.forEach((feature) => {
|
||||||
|
expect(feature).toHaveProperty('key');
|
||||||
|
expect(feature).toHaveProperty('labelKey');
|
||||||
|
expect(feature).toHaveProperty('descriptionKey');
|
||||||
|
expect(feature).toHaveProperty('defaultVisible');
|
||||||
|
expect(feature).toHaveProperty('components');
|
||||||
|
expect(typeof feature.key).toBe('string');
|
||||||
|
expect(typeof feature.labelKey).toBe('string');
|
||||||
|
expect(typeof feature.descriptionKey).toBe('string');
|
||||||
|
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||||
|
expect(typeof feature.components).toBe('object');
|
||||||
|
expect(feature.components).toHaveProperty('popup');
|
||||||
|
expect(feature.components).toHaveProperty('sidepanel');
|
||||||
|
expect(feature.components).toHaveProperty('tab');
|
||||||
|
|
||||||
|
// Optional UI properties for non-hidden features
|
||||||
|
if (feature.key !== 'dashboard') {
|
||||||
|
expect(feature).toHaveProperty('icon');
|
||||||
|
expect(feature).toHaveProperty('themeColorKey');
|
||||||
|
expect(typeof feature.themeColorKey).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have unique keys for each feature', () => {
|
||||||
|
const keys = FEATURES.map((f) => f.key);
|
||||||
|
const uniqueKeys = new Set(keys);
|
||||||
|
expect(uniqueKeys.size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFeatureByKey', () => {
|
||||||
|
it('should return dashboard feature', () => {
|
||||||
|
const feature = getFeatureByKey('dashboard');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('dashboard');
|
||||||
|
expect(feature?.labelKey).toBe('features:dashboard.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return timestamp feature', () => {
|
||||||
|
const feature = getFeatureByKey('timestamp');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('timestamp');
|
||||||
|
expect(feature?.labelKey).toBe('features:timestamp.title');
|
||||||
|
expect(feature?.themeColorKey).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return storageCleaner feature', () => {
|
||||||
|
const feature = getFeatureByKey('storageCleaner');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('storageCleaner');
|
||||||
|
expect(feature?.labelKey).toBe('features:storageCleaner.title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid key', () => {
|
||||||
|
const feature = getFeatureByKey('invalid' as any);
|
||||||
|
expect(feature).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultVisibleFeatureKeys', () => {
|
||||||
|
it('should return only visible features', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
visibleKeys.forEach((key) => {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
expect(feature?.defaultVisible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include dashboard, timestamp, storageCleaner, qrCode', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
expect(visibleKeys).toContain('dashboard');
|
||||||
|
expect(visibleKeys).toContain('timestamp');
|
||||||
|
expect(visibleKeys).toContain('storageCleaner');
|
||||||
|
expect(visibleKeys).toContain('qrCode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllFeatureKeys', () => {
|
||||||
|
it('should return all feature keys', () => {
|
||||||
|
const allKeys = getAllFeatureKeys();
|
||||||
|
expect(allKeys).toHaveLength(11);
|
||||||
|
expect(allKeys).toContain('dashboard');
|
||||||
|
expect(allKeys).toContain('timestamp');
|
||||||
|
expect(allKeys).toContain('storageCleaner');
|
||||||
|
expect(allKeys).toContain('qrCode');
|
||||||
|
expect(allKeys).toContain('textStatistics');
|
||||||
|
expect(allKeys).toContain('jwt');
|
||||||
|
expect(allKeys).toContain('jsonDiff');
|
||||||
|
expect(allKeys).toContain('base64Converter');
|
||||||
|
expect(allKeys).toContain('markdownToHtml');
|
||||||
|
expect(allKeys).toContain('htmlToMarkdown');
|
||||||
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultPageOrder', () => {
|
||||||
|
it('should exclude dashboard from page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).not.toContain('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include timestamp, storageCleaner, qrCode in page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).toContain('timestamp');
|
||||||
|
expect(pageOrder).toContain('storageCleaner');
|
||||||
|
expect(pageOrder).toContain('qrCode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have 10 items in page order', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).toHaveLength(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { type ComponentType, lazy } from 'react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
QrCode,
|
||||||
|
FileText,
|
||||||
|
Key,
|
||||||
|
GitCompareArrows,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Code,
|
||||||
|
File,
|
||||||
|
MousePointerClick,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
|
|
||||||
|
// 懒加载页面组件
|
||||||
|
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
|
||||||
|
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
|
||||||
|
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
|
||||||
|
const QrCodePage = lazy(() => import('@/pages/QrCode'));
|
||||||
|
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
||||||
|
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
||||||
|
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
||||||
|
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||||
|
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
||||||
|
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
||||||
|
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||||
|
|
||||||
|
export interface FeatureConfig {
|
||||||
|
key: PageType;
|
||||||
|
labelKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
themeColorKey?: PaletteColorKey;
|
||||||
|
icon?: ComponentType<LucideProps>;
|
||||||
|
defaultVisible: boolean;
|
||||||
|
components: {
|
||||||
|
popup: ComponentType;
|
||||||
|
sidepanel: ComponentType;
|
||||||
|
tab: ComponentType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEATURES: FeatureConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
labelKey: 'features:dashboard.title',
|
||||||
|
descriptionKey: '',
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: DashboardPage,
|
||||||
|
sidepanel: DashboardPage,
|
||||||
|
tab: DashboardPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
labelKey: 'features:timestamp.title',
|
||||||
|
descriptionKey: 'features:timestamp.description',
|
||||||
|
themeColorKey: 'primary',
|
||||||
|
icon: Clock,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: TimestampPage,
|
||||||
|
sidepanel: TimestampPage,
|
||||||
|
tab: TimestampPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'storageCleaner',
|
||||||
|
labelKey: 'features:storageCleaner.title',
|
||||||
|
descriptionKey: 'features:storageCleaner.description',
|
||||||
|
themeColorKey: 'warning',
|
||||||
|
icon: Database,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: StorageCleanerPage,
|
||||||
|
sidepanel: StorageCleanerPage,
|
||||||
|
tab: StorageCleanerPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qrCode',
|
||||||
|
labelKey: 'features:qrCode.title',
|
||||||
|
descriptionKey: 'features:qrCode.description',
|
||||||
|
themeColorKey: 'success',
|
||||||
|
icon: QrCode,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: QrCodePage,
|
||||||
|
sidepanel: QrCodePage,
|
||||||
|
tab: QrCodePage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'textStatistics',
|
||||||
|
labelKey: 'features:textStatistics.title',
|
||||||
|
descriptionKey: 'features:textStatistics.description',
|
||||||
|
themeColorKey: 'secondary',
|
||||||
|
icon: FileText,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: TextStatisticsPage,
|
||||||
|
sidepanel: TextStatisticsPage,
|
||||||
|
tab: TextStatisticsPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jwt',
|
||||||
|
labelKey: 'features:jwt.title',
|
||||||
|
descriptionKey: 'features:jwt.description',
|
||||||
|
themeColorKey: 'info',
|
||||||
|
icon: Key,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: JwtPage,
|
||||||
|
sidepanel: JwtPage,
|
||||||
|
tab: JwtPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jsonDiff',
|
||||||
|
labelKey: 'features:jsonDiff.title',
|
||||||
|
descriptionKey: 'features:jsonDiff.description',
|
||||||
|
themeColorKey: 'primary',
|
||||||
|
icon: GitCompareArrows,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: JsonToolsPage,
|
||||||
|
sidepanel: JsonToolsPage,
|
||||||
|
tab: JsonToolsPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'base64Converter',
|
||||||
|
labelKey: 'features:base64Converter.title',
|
||||||
|
descriptionKey: 'features:base64Converter.description',
|
||||||
|
themeColorKey: 'info',
|
||||||
|
icon: ArrowLeftRight,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: Base64ConverterPage,
|
||||||
|
sidepanel: Base64ConverterPage,
|
||||||
|
tab: Base64ConverterPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'markdownToHtml',
|
||||||
|
labelKey: 'features:markdownToHtml.title',
|
||||||
|
descriptionKey: 'features:markdownToHtml.description',
|
||||||
|
themeColorKey: 'secondary',
|
||||||
|
icon: Code,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: MarkdownToHtmlPage,
|
||||||
|
sidepanel: MarkdownToHtmlPage,
|
||||||
|
tab: MarkdownToHtmlPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'htmlToMarkdown',
|
||||||
|
labelKey: 'features:htmlToMarkdown.title',
|
||||||
|
descriptionKey: 'features:htmlToMarkdown.description',
|
||||||
|
themeColorKey: 'secondary',
|
||||||
|
icon: File,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: HtmlToMarkdownPage,
|
||||||
|
sidepanel: 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 {
|
||||||
|
return FEATURES.find((f) => f.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultVisibleFeatureKeys(): PageType[] {
|
||||||
|
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFeatureKeys(): PageType[] {
|
||||||
|
return FEATURES.map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPageOrder(): PageType[] {
|
||||||
|
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
if (pathname.includes('sidepanel')) {
|
||||||
|
return 'sidepanel';
|
||||||
|
}
|
||||||
|
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
|
||||||
|
return 'tab';
|
||||||
|
}
|
||||||
|
return 'popup';
|
||||||
|
}
|
||||||
@@ -1,880 +0,0 @@
|
|||||||
# Storage Cleaner Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a storage cleaner feature to the browser extension popup that allows users to clear localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, andress Workers for the current page.
|
|
||||||
|
|
||||||
**Architecture:** Add new StorageCleanerPage component with tab switching in the popup, using chrome.cookies API for cookies and script injection for other storage types. User preferences are persisted using Chrome Storage.
|
|
||||||
|
|
||||||
**Tech Stack:** React 19 + TypeScript, Material UI, Chrome Extension APIs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
entrypoints/popup/
|
|
||||||
├── App.tsx (modify: add tab switching)
|
|
||||||
└── pages/
|
|
||||||
├── TimestampPage.tsx (no change)
|
|
||||||
└── StorageCleanerPage.tsx (create: new storage cleaner page)
|
|
||||||
|
|
||||||
types/
|
|
||||||
└── storage.d.ts (modify: add storage cleaner types)
|
|
||||||
|
|
||||||
utils/
|
|
||||||
└── storageCleaner.ts (create: storage cleaning utilities)
|
|
||||||
|
|
||||||
wxt.config.ts (modify: add cookies permission)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Add TypeScript Types for Storage Cleaner
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `types/storage.d.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add storage cleaner types to StorageSchema and interfaces**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface StorageSchema {
|
|
||||||
'app/lastRoute': string;
|
|
||||||
'app/theme': string;
|
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerPreferences {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
selectedTypes: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerOptions {
|
|
||||||
localStorage: boolean;
|
|
||||||
sessionStorage: boolean;
|
|
||||||
indexedDB: boolean;
|
|
||||||
cookies: boolean;
|
|
||||||
cacheStorage: boolean;
|
|
||||||
serviceWorkers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorageCleanResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CleaningResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
localStorage?: StorageCleanResult;
|
|
||||||
sessionStorage?: StorageCleanResult;
|
|
||||||
indexedDB?: StorageCleanResult;
|
|
||||||
cookies?: StorageCleanResult;
|
|
||||||
cacheStorage?: StorageCleanResult;
|
|
||||||
serviceWorkers?: StorageCleanResult;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit TypeScript types**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add types/storage.d.ts
|
|
||||||
git commit -m "feat: add TypeScript types for storage cleaner"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Add Cookies Permission to Manifest
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `wxt.config.ts:10-18`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add 'cookies' permission to manifest**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage',
|
|
||||||
'unlimitedStorage',
|
|
||||||
'clipboardWrite',
|
|
||||||
'activeTab',
|
|
||||||
'scripting',
|
|
||||||
'tabs',
|
|
||||||
'debugger',
|
|
||||||
'cookies', // Add this line
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Test build to ensure manifest is valid**
|
|
||||||
|
|
||||||
Run: `npm run compile`
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit manifest changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add wxt.config.ts
|
|
||||||
git commit -m "feat: add cookies permission to manifest"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Create Storage Cleaning Utilities
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `utils/storageCleaner.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create storage cleaning utility file with helper functions**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
|
|
||||||
|
|
||||||
const RESTRICTED_PROTOCOLS = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
'file:',
|
|
||||||
'data:',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export async function getCurrentTab() {
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRestrictedUrl(url?: string): boolean {
|
|
||||||
if (!url) return true;
|
|
||||||
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const cookies = await chrome.cookies.getAll({ url });
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
await chrome.cookies.remove({
|
|
||||||
url,
|
|
||||||
name: cookie.name,
|
|
||||||
storeId: cookie.storeId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { success: true, count: cookies.length };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
const count = localStorage.length;
|
|
||||||
localStorage.clear();
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
const count = sessionStorage.length;
|
|
||||||
sessionStorage.clear();
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number } | { error: string }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
if (typeof indexedDB.databases === 'function') {
|
|
||||||
return indexedDB.databases().then(async (databases) => {
|
|
||||||
let count = 0;
|
|
||||||
for (const db of databases) {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
|
||||||
deleteReq.onblocked = () => {
|
|
||||||
console.warn('IndexedDB delete blocked:', db.name);
|
|
||||||
};
|
|
||||||
deleteReq.onsuccess = () => resolve();
|
|
||||||
deleteReq.onerror = () => reject();
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return { count };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
if ('error' in result.result) {
|
|
||||||
return { success: false, error: result.result.error };
|
|
||||||
}
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: async () => {
|
|
||||||
if ('caches' in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
for (const name of cacheNames) {
|
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return { count: cacheNames.length };
|
|
||||||
}
|
|
||||||
return { count: 0 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: async () => {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister();
|
|
||||||
}
|
|
||||||
return { count: registrations.length };
|
|
||||||
}
|
|
||||||
return { count: 0 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearStorage(
|
|
||||||
tabId: number,
|
|
||||||
url: string,
|
|
||||||
options: StorageCleanerOptions,
|
|
||||||
): Promise<CleaningResult> {
|
|
||||||
const result: CleaningResult = { success: true };
|
|
||||||
|
|
||||||
if (options.localStorage) {
|
|
||||||
result.localStorage = await injectClearLocalStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.sessionStorage) {
|
|
||||||
result.sessionStorage = await injectClearSessionStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.indexedDB) {
|
|
||||||
result.indexedDB = await injectClearIndexedDB(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cookies) {
|
|
||||||
result.cookies = await clearCookies(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cacheStorage) {
|
|
||||||
result.cacheStorage = await injectClearCacheStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.serviceWorkers) {
|
|
||||||
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any operation failed
|
|
||||||
const failures = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
result.success = false;
|
|
||||||
result.error = '部分清理失败';
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCleaningResult(result: CleaningResult): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (result.localStorage?.success) {
|
|
||||||
parts.push(`${result.localStorage.count} 个 localStorage`);
|
|
||||||
}
|
|
||||||
if (result.sessionStorage?.success) {
|
|
||||||
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
|
|
||||||
}
|
|
||||||
if (result.indexedDB?.success) {
|
|
||||||
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
|
|
||||||
}
|
|
||||||
if (result.cookies?.success) {
|
|
||||||
parts.push(`${result.cookies.count} 个 Cookies`);
|
|
||||||
}
|
|
||||||
if (result.cacheStorage?.success) {
|
|
||||||
parts.push(`${result.cacheStorage.count} 个 Cache`);
|
|
||||||
}
|
|
||||||
if (result.serviceWorkers?.success) {
|
|
||||||
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return '该页面没有可清理的存储数据';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `清理了 ${parts.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyResult(result: CleaningResult): boolean {
|
|
||||||
const values = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
|
|
||||||
);
|
|
||||||
return values.length === 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit storage cleaning utilities**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add utils/storageCleaner.ts
|
|
||||||
git commit -m "feat: add storage cleaning utility functions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Create StorageCleanerPage Component
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create StorageCleanerPage component with UI and logic**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
FormControlLabel,
|
|
||||||
Alert,
|
|
||||||
Snackbar,
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
|
||||||
import {
|
|
||||||
getCurrentTab,
|
|
||||||
isRestrictedUrl,
|
|
||||||
clearStorage,
|
|
||||||
formatCleaningResult,
|
|
||||||
isEmptyResult,
|
|
||||||
} from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load tab info and user preferences
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInfo = async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.url) {
|
|
||||||
setError('无法获取当前标签页');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestrictedUrl(tab.url)) {
|
|
||||||
setError('存储清理功能不支持此页面');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDomain(new URL(tab.url).hostname);
|
|
||||||
|
|
||||||
// Load user preferences
|
|
||||||
const prefs = await storageUtil.get(
|
|
||||||
'storageCleaner/preferences',
|
|
||||||
DEFAULT_PREFERENCES,
|
|
||||||
);
|
|
||||||
setAutoRefresh(prefs.autoRefresh);
|
|
||||||
setOptions(prefs.selectedTypes);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
|
||||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClean = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.id || !tab.url) {
|
|
||||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
|
||||||
setResult(cleaningResult);
|
|
||||||
|
|
||||||
// Save user preferences
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh,
|
|
||||||
selectedTypes: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto refresh if enabled
|
|
||||||
if (autoRefresh && cleaningResult.success) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowConfirm(false);
|
|
||||||
}
|
|
||||||
}, [options, autoRefresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
if (tab?.id) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
<Alert severity="error" icon={<WarningIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
当前页面: {domain || '加载中...'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Storage Type Options */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
|
||||||
选择要清理的存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.localStorage}
|
|
||||||
onChange={() => handleOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="localStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
onChange={() => handleOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="sessionStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.indexedDB}
|
|
||||||
onChange={() => handleOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="IndexedDB"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cookies}
|
|
||||||
onChange={() => handleOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cookies"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
onChange={() => handleOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cache Storage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
onChange={() => handleOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Service Workers"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Auto Refresh Option */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="清理完成后自动刷新页面"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '清理中...' : '清理'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
|
||||||
{result && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
{!autoRefresh && result.success && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
{showConfirm && (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6">确认清理</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
|
||||||
将清理以下存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
{options.localStorage && (
|
|
||||||
<Typography variant="body2">- localStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.sessionStorage && (
|
|
||||||
<Typography variant="body2">- sessionStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
|
||||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
|
||||||
{options.cacheStorage && (
|
|
||||||
<Typography variant="body2">- Cache Storage</Typography>
|
|
||||||
)}
|
|
||||||
{options.serviceWorkers && (
|
|
||||||
<Typography variant="body2">- Service Workers</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textAlign: 'center', mb: 1 }}
|
|
||||||
>
|
|
||||||
此操作不可撤销。
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" color="error" onClick={handleClean}>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert severity="info" variant="filled">
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit StorageCleanerPage component**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add entrypoints/popup/pages/StorageCleanerPage.tsx
|
|
||||||
git commit -m "feat: add StorageCleanerPage component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Update App.tsx with Tab Switching
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `entrypoints/popup/App.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add tab switching logic to App.tsx**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Box, Button } from '@mui/material';
|
|
||||||
import TimestampPage from './pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
type PageType = 'timestamp' | 'storageCleaner';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('timestamp')}
|
|
||||||
>
|
|
||||||
时间戳
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('storageCleaner')}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run type check**
|
|
||||||
|
|
||||||
Run: `npm run compile`
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit App.tsx changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add entrypoints/popup/App.tsx
|
|
||||||
git commit -m "feat: add tab switching to App component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Build and Test
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- No file changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build the extension**
|
|
||||||
|
|
||||||
Run: `npm run build`
|
|
||||||
Expected: Build succeeds with no errors
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run lint check**
|
|
||||||
|
|
||||||
Run: `npm run lint`
|
|
||||||
Expected: No linting errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Load extension in Chrome for manual testing**
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
|
|
||||||
1. Open Chrome and navigate to `chrome://extensions/`
|
|
||||||
2. Enable Developer Mode
|
|
||||||
3. Click "Load unpacked"
|
|
||||||
4. Select `.output/chrome-mv3` directory
|
|
||||||
5. Test on a regular web page (e.g., example.com)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit successful implementation**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit --allow-empty -m "feat: complete storage cleaner feature implementation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
After implementation, verify:
|
|
||||||
|
|
||||||
- [ ] Tab switching works between timestamp and storage cleaner
|
|
||||||
- [ ] Current domain displays correctly
|
|
||||||
- [ ] All storage type checkboxes toggle correctly
|
|
||||||
- [ ] Auto refresh checkbox persists across sessions
|
|
||||||
- [ ] Clear confirmation dialog appears
|
|
||||||
- [ ] Confirmation dialog shows selected storage types
|
|
||||||
- [ ] localStorage clears successfully
|
|
||||||
- [ ] sessionStorage clears successfully
|
|
||||||
- [ ] IndexedDB clears successfully (or shows error if unavailable)
|
|
||||||
- [ ] Cookies clear successfully
|
|
||||||
- [ ] Clear httponly and secure cookies
|
|
||||||
- [ ] Cache Storage clears successfully
|
|
||||||
- [ ] Service Workers unregister successfully
|
|
||||||
- [ ] Result message displays correctly
|
|
||||||
- [ ] Empty state shows friendly message
|
|
||||||
- [ ] Auto refresh works
|
|
||||||
- [ ] Manual refresh button appears when auto-refresh is off
|
|
||||||
- [ ] Restricted pages show error message
|
|
||||||
- [ ] Snackbar notifications appear correctly
|
|
||||||
- [ ] Test on localhost
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If issues occur during testing:
|
|
||||||
|
|
||||||
1. Revert to before implementation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git reset --hard <commit-before-start>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Or revert specific files:
|
|
||||||
```bash
|
|
||||||
git checkout HEAD -- types/storage.d.ts wxt.config.ts utils/storageCleaner.ts entrypoints/popup/App.tsx entrypoints/popup/pages/StorageCleanerPage.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The popup closes automatically when the page is refreshed - this is expected behavior
|
|
||||||
- IndexedDB.databases() may not be available in all browser versions; the fallback handles this
|
|
||||||
- Chrome Cookies API requires explicit permission, which is added to the manifest
|
|
||||||
- User preferences are persisted using the existing chromeStorage.ts utility
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
# 存储清理功能设计文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
- 提供便捷的页面存储清理功能
|
|
||||||
- 支持多种存储类型清理
|
|
||||||
- 提供清理结果反馈
|
|
||||||
- 支持清理后自动刷新页面
|
|
||||||
|
|
||||||
## 架构设计
|
|
||||||
|
|
||||||
### 组件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
entrypoints/popup/pages/
|
|
||||||
├── TimestampPage.tsx (现有:时间戳转换页面)
|
|
||||||
└── StorageCleanerPage.tsx (新增:存储清理页面)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 页面布局
|
|
||||||
|
|
||||||
在弹窗中添加标签页切换功能,用户可以在时间戳转换和存储清理之间切换。
|
|
||||||
|
|
||||||
**路由实现方案:**
|
|
||||||
|
|
||||||
使用简单的状态管理进行页面切换:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// App.tsx
|
|
||||||
type PageType = 'timestamp' | 'storageCleaner';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('timestamp')}
|
|
||||||
>
|
|
||||||
时间戳
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('storageCleaner')}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 用户界面设计
|
|
||||||
|
|
||||||
### 页面组成
|
|
||||||
|
|
||||||
1. **头部区域**
|
|
||||||
- 标题:"存储清理"
|
|
||||||
- 当前域名显示(自动从活动标签页获取)
|
|
||||||
|
|
||||||
2. **存储类型选择区域**
|
|
||||||
- 勾选框:localStorage
|
|
||||||
- 勾选框:sessionStorage
|
|
||||||
- 勾选框:IndexedDB
|
|
||||||
- 勾选框:Cookies
|
|
||||||
- 勾选框:Cache Storage
|
|
||||||
- 勾选框:Service Workers
|
|
||||||
|
|
||||||
3. **自动刷新选项**
|
|
||||||
- 复选框:清理完成后自动刷新页面(默认勾选)
|
|
||||||
|
|
||||||
4. **操作区域**
|
|
||||||
- 清理按钮
|
|
||||||
|
|
||||||
5. **结果显示区域**
|
|
||||||
- 清理成功/失败提示
|
|
||||||
- 清理详情统计(如:"清理了 5 个 localStorage, 3 个 cookies")
|
|
||||||
- 刷新页面按钮(当未勾选自动刷新时显示)
|
|
||||||
|
|
||||||
## 技术实现细节
|
|
||||||
|
|
||||||
### 获取当前标签页域名
|
|
||||||
|
|
||||||
使用 Chrome Tabs API 获取当前活动标签页,并过滤受限页面:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
|
|
||||||
// 检查受限页面
|
|
||||||
const restrictedProtocols = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
'file:',
|
|
||||||
'data:',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!tab?.url || restrictedProtocols.some((p) => tab.url!.startsWith(p))) {
|
|
||||||
throw new Error('存储清理功能不支持此页面');
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = new URL(tab.url).hostname;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 清理 Cookies(使用 chrome.cookies API)
|
|
||||||
|
|
||||||
在扩展环境中直接执行,不需要注入页面:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const cookies = await chrome.cookies.getAll({ url: tab.url });
|
|
||||||
let count = 0;
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
await chrome.cookies.remove({
|
|
||||||
url: tab.url,
|
|
||||||
name: cookie.name,
|
|
||||||
storeId: cookie.storeId,
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 注入脚本清理其他存储
|
|
||||||
|
|
||||||
使用 `chrome.scripting.executeScript` 注入清理脚本:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const result = await chrome.scripting.executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
func: () => {
|
|
||||||
// 清理逻辑在页面上下文中执行
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
需要注入到页面执行的存储清理逻辑:
|
|
||||||
|
|
||||||
#### 清理 localStorage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = localStorage.length;
|
|
||||||
localStorage.clear();
|
|
||||||
return count;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 sessionStorage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = sessionStorage.length;
|
|
||||||
sessionStorage.clear();
|
|
||||||
return count;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 IndexedDB
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 检查 indexedDB.databases 方法是否可用
|
|
||||||
if (typeof indexedDB.databases === 'function') {
|
|
||||||
const databases = await indexedDB.databases();
|
|
||||||
let count = 0;
|
|
||||||
for (const db of databases) {
|
|
||||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
|
||||||
deleteReq.onblocked = () => {
|
|
||||||
console.warn('IndexedDB delete blocked:', db.name);
|
|
||||||
};
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
deleteReq.onsuccess = resolve;
|
|
||||||
deleteReq.onerror = reject;
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
// 降级方案:由于无法获取所有数据库名称,提示返回特殊值表示需要手动操作
|
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 Cache Storage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if ('caches' in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
for (const name of cacheNames) {
|
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return cacheNames.length;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 注销 Service Workers
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
let count = 0;
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister();
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据流
|
|
||||||
|
|
||||||
1. 页面加载时获取当前标签页 URL 并显示域名
|
|
||||||
2. 检查是否为受限页面(chrome://, about:// 等),如果是则显示错误提示
|
|
||||||
3. 用户勾选要清理的存储类型
|
|
||||||
4. 用户选择是否自动刷新页面
|
|
||||||
5. 用户点击清理按钮
|
|
||||||
6. 弹出确认对话框询问用户确认
|
|
||||||
7. 确认后执行清理:
|
|
||||||
- 如果选择 Cookies:直接使用 chrome.cookies API 删除
|
|
||||||
- 其他存储类型:向页面注入清理脚本
|
|
||||||
8. 收集所有清理结果并统计
|
|
||||||
9. 显示清理结果
|
|
||||||
10. 如果勾选"自动刷新"或用户点击"刷新页面"按钮,执行页面刷新
|
|
||||||
|
|
||||||
**注意:** 当触发页面刷新时,popup 会自动关闭。需要在刷新前显示提示信息。
|
|
||||||
|
|
||||||
### 页面刷新
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 显示刷新提示
|
|
||||||
setRefreshing(true);
|
|
||||||
setTimeout(async () => {
|
|
||||||
await chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500); // 1.5秒延迟让用户看到提示信息
|
|
||||||
```
|
|
||||||
|
|
||||||
### 空状态处理
|
|
||||||
|
|
||||||
当所有存储类型清理返回 0 时,显示友好的提示:
|
|
||||||
|
|
||||||
```
|
|
||||||
该页面没有可清理的存储数据
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
| 错误场景 | 处理方式 |
|
|
||||||
| ------------------------------- | ---------------------------------------- |
|
|
||||||
| 无法获取当前标签页 | 显示错误提示:"无法获取当前标签页" |
|
|
||||||
| 受限页面(chrome://, about://) | 显示错误提示:"存储清理功能不支持此页面" |
|
|
||||||
| 无法访问页面 URL | 显示错误提示:"无法访问此页面" |
|
|
||||||
| IndexedDB onblocked | 显示警告但继续执行其他清理 |
|
|
||||||
| IndexedDB.databases 不可用 | 使用降级方案或提示用户手动清除 |
|
|
||||||
| 清理失败 | 显示具体错误信息 |
|
|
||||||
| Cookies 删除失败 | 记录错误,显示清理失败提示 |
|
|
||||||
| 无权限 | 提示用户刷新扩展或检查权限 |
|
|
||||||
| 脚本注入失败 | 显示错误提示:"无法注入清理脚本" |
|
|
||||||
|
|
||||||
**Popup 生命周期说明:**
|
|
||||||
|
|
||||||
- Popup 在页面失去焦点时会关闭
|
|
||||||
- 刷新页面后 Popup 会自动关闭
|
|
||||||
- 需要在刷新前显示提示:"页面即将刷新,Popup 将关闭"
|
|
||||||
|
|
||||||
## 用户偏好持久化
|
|
||||||
|
|
||||||
使用现有的 `chromeStorage.ts` 工具保存用户偏好:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 保存用户偏好
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh: true, // 默认勾选自动刷新
|
|
||||||
selectedTypes: {
|
|
||||||
// 可以保存用户上次选择的存储类型
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 读取用户偏好
|
|
||||||
const preferences = await storageUtil.get('storageCleaner/preferences', {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限需求
|
|
||||||
|
|
||||||
需要在 manifest 中添加 `cookies` 权限:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage',
|
|
||||||
'unlimitedStorage',
|
|
||||||
'clipboardWrite',
|
|
||||||
'activeTab',
|
|
||||||
'scripting',
|
|
||||||
'tabs',
|
|
||||||
'debugger',
|
|
||||||
'cookies', // 新增
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
## TypeScript 类型定义
|
|
||||||
|
|
||||||
在现有 `types/storage.d.ts` 中添加存储清理相关的类型:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface StorageSchema {
|
|
||||||
'app/lastRoute': string;
|
|
||||||
'app/theme': string;
|
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerPreferences {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
selectedTypes: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerOptions {
|
|
||||||
localStorage: boolean;
|
|
||||||
sessionStorage: boolean;
|
|
||||||
indexedDB: boolean;
|
|
||||||
cookies: boolean;
|
|
||||||
cacheStorage: boolean;
|
|
||||||
serviceWorkers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorageCleanResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CleaningResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
localStorage?: StorageCleanResult;
|
|
||||||
sessionStorage?: StorageCleanResult;
|
|
||||||
indexedDB?: StorageCleanResult;
|
|
||||||
cookies?: StorageCleanResult;
|
|
||||||
cacheStorage?: StorageCleanResult;
|
|
||||||
serviceWorkers?: StorageCleanResult;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试计划
|
|
||||||
|
|
||||||
1. 测试各种存储类型的单独清理
|
|
||||||
2. 测试同时清理多种存储类型
|
|
||||||
3. 测试自动刷新功能
|
|
||||||
4. 测试手动刷新按钮
|
|
||||||
5. 测试无存储数据时的清理(显示空状态提示)
|
|
||||||
6. 测试无法访问页面的错误处理
|
|
||||||
7. 测试受限页面(chrome://, about://, file://, data://)
|
|
||||||
8. 测试 IndexedDB onblocked 场景
|
|
||||||
9. 测试 httponly 和 secure cookies 清理
|
|
||||||
10. 测试本地开发环境(localhost)
|
|
||||||
11. 测试用户偏好持久化
|
|
||||||
|
|
||||||
## 后续优化
|
|
||||||
|
|
||||||
- 显示清理前的存储使用情况
|
|
||||||
- 支持批量清理多个标签页
|
|
||||||
- 支持自定义域名清理
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
createAllContextMenus,
|
||||||
|
parseContextMenuClick,
|
||||||
|
CONTEXT_MENU_CONFIGS,
|
||||||
|
MAX_PAYLOAD_LENGTH,
|
||||||
|
} from '@/utils/contextMenu';
|
||||||
|
import { MessageAction } from '@/utils/messages';
|
||||||
|
|
||||||
|
describe('background 菜单注册与分流', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('菜单注册', () => {
|
||||||
|
it('应该调用 createAllContextMenus 创建所有菜单项', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建父级菜单 Testing Tools', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'testing-tools-parent',
|
||||||
|
title: 'Testing Tools',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建 JWT 解析子菜单', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'jwt',
|
||||||
|
title: '🔑 解析 JWT',
|
||||||
|
parentId: 'testing-tools-parent',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建网页链接转二维码子菜单', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'qrCode-page',
|
||||||
|
title: '🔗 网页链接转二维码',
|
||||||
|
contexts: ['page'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('菜单点击解析', () => {
|
||||||
|
const createMockOnClickData = (
|
||||||
|
overrides: Partial<chrome.contextMenus.OnClickData> = {},
|
||||||
|
): chrome.contextMenus.OnClickData => ({
|
||||||
|
menuItemId: 'test',
|
||||||
|
editable: false,
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'jwt',
|
||||||
|
selectionText: 'test-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'jwt', payload: 'test-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有 selectionText 和 srcUrl 时应返回错误', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'unknown',
|
||||||
|
pageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('unknown', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: '无法获取有效数据',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本超过最大长度限制时应截断', () => {
|
||||||
|
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'textStatistics',
|
||||||
|
selectionText: longText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本未超过最大长度限制时应保持原样', () => {
|
||||||
|
const shortText = 'short text';
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'textStatistics',
|
||||||
|
selectionText: shortText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理页面 URL 菜单点击', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'storageCleaner',
|
||||||
|
pageUrl: 'https://example.com/page',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('storageCleaner', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('消息类型定义', () => {
|
||||||
|
it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => {
|
||||||
|
expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+176
-38
@@ -1,51 +1,189 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
|
||||||
|
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
// 监听扩展安装或更新事件
|
// 1. 扩展初次安装或更新时,注册右键上下文菜单
|
||||||
browser.runtime.onInstalled.addListener(async ({ reason }) => {
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
if (reason === 'install') {
|
createAllContextMenus();
|
||||||
console.log('Extension installed for the first time');
|
});
|
||||||
} else if (reason === 'update') {
|
|
||||||
console.log('Extension updated to a new version');
|
// 2. 右键点击路由
|
||||||
|
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
||||||
|
const result = parseContextMenuClick(info.menuItemId as string, info);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
if (result.error) {
|
||||||
|
console.warn('[Context Menu Warning]', result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有标签页
|
const { featureKey, payload } = result.data;
|
||||||
const tabs = await browser.tabs.query({});
|
|
||||||
|
|
||||||
// 过滤不合法或受限制的 URL
|
try {
|
||||||
const targetTabs = tabs.filter((tab) => {
|
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||||
if (!tab.id || !tab.url) return false;
|
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||||
const restrictedProtocols = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
];
|
|
||||||
return !restrictedProtocols.some((protocol) => tab.url!.startsWith(protocol));
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
if (isSidePanelOpen) {
|
||||||
targetTabs.map((tab) =>
|
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||||
browser.scripting
|
return;
|
||||||
.executeScript({
|
}
|
||||||
target: { tabId: tab.id! },
|
} catch (err) {
|
||||||
files: ['/content-scripts/content.js'],
|
console.debug('[Context Menu] Side panel pipeline is not available:', err);
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
|
||||||
console.warn(`Failed to inject script into tab ${tab.id}:`, err.message);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
await saveContextMenuData({ featureKey, payload });
|
||||||
console.log(
|
|
||||||
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
|
try {
|
||||||
);
|
await browser.action.openPopup();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener((tabId) => {
|
// 监听扩展图标点击事件,安全激活侧边栏
|
||||||
console.log('加载完成的 Tab ID:', tabId);
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
|
if (tab.id) {
|
||||||
|
try {
|
||||||
|
await browser.sidePanel.open({ tabId: tab.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to open side panel via extension action clicked:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
const { tabId, delay = 0 } = message.data;
|
||||||
|
|
||||||
|
const executeReload = () => {
|
||||||
|
browser.tabs.reload(tabId).catch((err) => {
|
||||||
|
console.error('Failed to execute tab reload operation:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (delay <= 0) {
|
||||||
|
executeReload();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
|
||||||
|
|
||||||
|
// 创建一次性 Alarm,由浏览器内核保障触发(不受 Service Worker 生命周期影响)
|
||||||
|
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
||||||
|
|
||||||
|
// 兜底清理:若 alarm 因异常未触发,delay 后 5 秒强制移除监听器并清理 alarm
|
||||||
|
const cleanupTimeout = setTimeout(() => {
|
||||||
|
browser.alarms.onAlarm.removeListener(alarmListener);
|
||||||
|
browser.alarms.clear(alarmName).catch(() => {});
|
||||||
|
}, delay + 5000);
|
||||||
|
|
||||||
|
const alarmListener = (alarm: { name: string }) => {
|
||||||
|
if (alarm.name !== alarmName) return;
|
||||||
|
|
||||||
|
clearTimeout(cleanupTimeout);
|
||||||
|
executeReload();
|
||||||
|
browser.alarms.onAlarm.removeListener(alarmListener);
|
||||||
|
browser.alarms.clear(alarmName).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
browser.alarms.onAlarm.addListener(alarmListener);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
|
import { initMessageHandler } from './content/messageHandler';
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_start',
|
runAt: 'document_end',
|
||||||
main() {
|
main() {
|
||||||
// Content script placeholder
|
initMessageHandler();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
||||||
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
|
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||||
|
|
||||||
|
function getI18nText(key: string, fallback: string): string {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
||||||
|
return chrome.i18n.getMessage(key) || fallback;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertTimestamp(input: string): string {
|
||||||
|
const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp');
|
||||||
|
const num = Number(input.trim());
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return invalidText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1e12 判定毫秒级/秒级时间戳兼容
|
||||||
|
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
|
||||||
|
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return invalidText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastClickX = 0;
|
||||||
|
let lastClickY = 0;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
(e) => {
|
||||||
|
lastClickX = e.clientX;
|
||||||
|
lastClickY = e.clientY;
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export function initContextMenuHandler(): void {
|
||||||
|
const dismissPopover = (): void => {
|
||||||
|
hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', dismissPopover, { passive: true });
|
||||||
|
document.addEventListener('scroll', dismissPopover, { passive: true });
|
||||||
|
window.addEventListener('resize', dismissPopover, { passive: true });
|
||||||
|
|
||||||
|
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
|
||||||
|
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
|
||||||
|
|
||||||
|
switch (featureKey) {
|
||||||
|
case 'timestamp': {
|
||||||
|
const result = convertTimestamp(payload);
|
||||||
|
showTimestampResult(lastClickX, lastClickY, payload, result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'textStatistics': {
|
||||||
|
const stats = getTextStats(payload);
|
||||||
|
showTextStatsResult(lastClickX, lastClickY, payload, stats);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
hidePopover();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { initContextMenuHandler } from './contextMenuHandler';
|
||||||
|
|
||||||
|
export function initMessageHandler(): void {
|
||||||
|
initContextMenuHandler();
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
const POPOVER_ID = 'testing-tools-popover';
|
||||||
|
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
|
||||||
|
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(POPOVER_STYLE_ID)) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = POPOVER_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#${POPOVER_ID} {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483647;
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID}.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0a0b0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #808090;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-close:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-content {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-label {
|
||||||
|
color: #808090;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-value {
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808090;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreatePopover(): HTMLElement {
|
||||||
|
let popover = document.getElementById(POPOVER_ID);
|
||||||
|
if (!popover) {
|
||||||
|
injectStyles();
|
||||||
|
popover = document.createElement('div');
|
||||||
|
popover.id = POPOVER_ID;
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
}
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPopover(popover: HTMLElement, x: number, y: number): void {
|
||||||
|
// 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽
|
||||||
|
const rect = popover.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点
|
||||||
|
let top = y + 8;
|
||||||
|
|
||||||
|
if (left + rect.width > viewportWidth - 16) {
|
||||||
|
left = viewportWidth - rect.width - 16;
|
||||||
|
}
|
||||||
|
if (left < 16) left = 16;
|
||||||
|
|
||||||
|
if (top + rect.height > viewportHeight - 16) {
|
||||||
|
top = y - rect.height - 8;
|
||||||
|
}
|
||||||
|
if (top < 16) top = 16;
|
||||||
|
|
||||||
|
popover.style.left = `${left}px`;
|
||||||
|
popover.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function showPopover(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
contentHtml: string,
|
||||||
|
title?: string,
|
||||||
|
duration: number = 5000,
|
||||||
|
): void {
|
||||||
|
const popover = getOrCreatePopover();
|
||||||
|
|
||||||
|
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
|
||||||
|
popover.innerHTML = '';
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'popover-header';
|
||||||
|
|
||||||
|
const titleSpan = document.createElement('span');
|
||||||
|
titleSpan.className = 'popover-title';
|
||||||
|
titleSpan.textContent = title; // ✅ 强安全性护航
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'popover-close';
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
header.appendChild(titleSpan);
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
popover.appendChild(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentContainer = document.createElement('div');
|
||||||
|
contentContainer.className = 'popover-content';
|
||||||
|
contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义
|
||||||
|
popover.appendChild(contentContainer);
|
||||||
|
|
||||||
|
// 提前移除激活类名,使 visibility: hidden 起效以供测量
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
positionPopover(popover, x, y);
|
||||||
|
popover.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hideTimeout) clearTimeout(hideTimeout);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
hidePopover();
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hidePopover(): void {
|
||||||
|
const popover = document.getElementById(POPOVER_ID);
|
||||||
|
if (popover) {
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
}
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
|
||||||
|
// 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗
|
||||||
|
const cleanTimestamp = escapeHtml(timestamp);
|
||||||
|
const cleanResult = escapeHtml(result);
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="popover-label">输入时间戳</div>
|
||||||
|
<div class="popover-value">${cleanTimestamp}</div>
|
||||||
|
<div class="popover-label">转换结果</div>
|
||||||
|
<div class="popover-value">${cleanResult}</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '⏰ 时间戳转换');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTextStatsResult(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
text: string,
|
||||||
|
stats: { characters: number; words: number; lines: number; bytes: number },
|
||||||
|
): void {
|
||||||
|
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||||
|
// 对选中的脏文本先进行严格转义
|
||||||
|
const cleanText = escapeHtml(truncatedText);
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="popover-label">选中文本</div>
|
||||||
|
<div class="popover-value">${cleanText}</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字符</div>
|
||||||
|
<div class="stat-value">${stats.characters}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">单词</div>
|
||||||
|
<div class="stat-value">${stats.words}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">行数</div>
|
||||||
|
<div class="stat-value">${stats.lines}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字节</div>
|
||||||
|
<div class="stat-value">${stats.bytes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '📊 文本统计');
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { GripVertical, RefreshCw } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import type { PageType, StorageSchema } from '@/types/storage';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { PaletteColorKey } from '@/config/features';
|
||||||
|
import {
|
||||||
|
getAllFeatureKeys,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getFeatureByKey,
|
||||||
|
} from '@/config/features';
|
||||||
|
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||||
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||||
|
primary: '#1976d2',
|
||||||
|
success: '#2e7d32',
|
||||||
|
warning: '#e65100',
|
||||||
|
error: '#c62828',
|
||||||
|
secondary: '#9c27b0',
|
||||||
|
info: '#0288d1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
|
||||||
|
|
||||||
|
const isValidPage = (page: unknown): page is PageType => {
|
||||||
|
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidPageList = (pages: unknown): pages is PageType[] => {
|
||||||
|
return Array.isArray(pages) && pages.every(isValidPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowType = 'popup' | 'sidepanel' | 'tab';
|
||||||
|
|
||||||
|
interface SortableFeatureRowProps {
|
||||||
|
pageKey: PageType;
|
||||||
|
isLast: boolean;
|
||||||
|
isChecked: boolean;
|
||||||
|
isDisabled: boolean;
|
||||||
|
onToggle: (key: PageType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableFeatureRow({
|
||||||
|
pageKey,
|
||||||
|
isLast,
|
||||||
|
isChecked,
|
||||||
|
isDisabled,
|
||||||
|
onToggle,
|
||||||
|
}: SortableFeatureRowProps) {
|
||||||
|
const { t } = useTranslation(['features']);
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: pageKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feature = getFeatureByKey(pageKey);
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
const colorKey = feature.themeColorKey ?? 'primary';
|
||||||
|
const colorCode = getColorCode(colorKey);
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 1 : 'auto',
|
||||||
|
position: 'relative' as const,
|
||||||
|
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
|
||||||
|
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-muted ${
|
||||||
|
isLast ? '' : 'border-b border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
|
||||||
|
{/* 拖拽手柄 */}
|
||||||
|
<div
|
||||||
|
className="drag-handle text-muted-foreground cursor-grab touch-none transition-colors duration-200 hover:text-foreground active:cursor-grabbing"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
|
||||||
|
>
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 功能图标 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${colorCode}1a`,
|
||||||
|
color: colorCode,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{feature.icon && <feature.icon size={20} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文本信息 */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-bold text-[0.95rem] leading-tight text-foreground">
|
||||||
|
{t(feature.labelKey)}
|
||||||
|
</div>
|
||||||
|
{feature.descriptionKey && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
title={t(feature.descriptionKey)}
|
||||||
|
>
|
||||||
|
{t(feature.descriptionKey)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 开关 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={isChecked}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => onToggle(pageKey)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
||||||
|
isChecked ? 'bg-primary' : 'bg-muted'
|
||||||
|
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-200 ${
|
||||||
|
isChecked ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { t } = useTranslation(['features', 'common']);
|
||||||
|
|
||||||
|
const initialWindowType = useMemo(() => {
|
||||||
|
if (typeof window === 'undefined') return 'popup';
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const tab = params.get('tab');
|
||||||
|
if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') {
|
||||||
|
return tab as WindowType;
|
||||||
|
}
|
||||||
|
return 'popup';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [windowType, setWindowType] = useState<WindowType>(initialWindowType);
|
||||||
|
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
||||||
|
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const { snackbarProps, showMessage } = useSnackbarState();
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const configKeys = useMemo(() => {
|
||||||
|
switch (windowType) {
|
||||||
|
case 'sidepanel':
|
||||||
|
return {
|
||||||
|
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
|
||||||
|
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
|
||||||
|
};
|
||||||
|
case 'tab':
|
||||||
|
return {
|
||||||
|
visible: 'app/tabVisiblePages' as keyof StorageSchema,
|
||||||
|
order: 'app/tabPageOrder' as keyof StorageSchema,
|
||||||
|
};
|
||||||
|
case 'popup':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
visible: 'app/popupVisiblePages' as keyof StorageSchema,
|
||||||
|
order: 'app/popupPageOrder' as keyof StorageSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [windowType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('tab', windowType);
|
||||||
|
window.history.replaceState({}, '', url.toString());
|
||||||
|
}, [windowType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setIsLoaded(false);
|
||||||
|
try {
|
||||||
|
const [savedVisible, savedOrder] = await Promise.all([
|
||||||
|
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
|
||||||
|
storageUtil.get(configKeys.order, getDefaultPageOrder()),
|
||||||
|
]);
|
||||||
|
setVisiblePages(
|
||||||
|
isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(),
|
||||||
|
);
|
||||||
|
setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
setVisiblePages(getDefaultVisibleFeatureKeys());
|
||||||
|
setPageOrder(getDefaultPageOrder());
|
||||||
|
} finally {
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadConfig().catch(console.error);
|
||||||
|
}, [configKeys]);
|
||||||
|
|
||||||
|
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
||||||
|
showMessage(message, { severity });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageToggle = async (page: PageType) => {
|
||||||
|
const isCurrentlyVisible = visiblePages.includes(page);
|
||||||
|
let newPages: PageType[];
|
||||||
|
|
||||||
|
if (isCurrentlyVisible) {
|
||||||
|
if (visiblePages.length <= 1) {
|
||||||
|
showToast('至少需要保留一个可见页面', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newPages = visiblePages.filter((p) => p !== page);
|
||||||
|
} else {
|
||||||
|
newPages = [...visiblePages, page];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageUtil.set(configKeys.visible, newPages);
|
||||||
|
setVisiblePages(newPages);
|
||||||
|
const feature = getFeatureByKey(page);
|
||||||
|
const label = feature ? t(feature.labelKey) : page;
|
||||||
|
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save config:', error);
|
||||||
|
showToast('保存失败', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = pageOrder.indexOf(active.id as PageType);
|
||||||
|
const newIndex = pageOrder.indexOf(over.id as PageType);
|
||||||
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
|
||||||
|
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
|
||||||
|
setPageOrder(newOrder);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await storageUtil.set(configKeys.order, newOrder);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save order:', error);
|
||||||
|
showToast('排序保存失败', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreDefaults = async () => {
|
||||||
|
try {
|
||||||
|
const defaults = getDefaultVisibleFeatureKeys();
|
||||||
|
const defaultOrder = getDefaultPageOrder();
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
storageUtil.set(configKeys.visible, defaults),
|
||||||
|
storageUtil.set(configKeys.order, defaultOrder),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setVisiblePages(defaults);
|
||||||
|
setPageOrder(defaultOrder);
|
||||||
|
showToast('已恢复当前模式默认设置', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to restore defaults:', error);
|
||||||
|
showToast('恢复失败', 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
|
||||||
|
if (newType !== null) {
|
||||||
|
setWindowType(newType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app min-h-screen bg-background flex flex-col">
|
||||||
|
<PageErrorBoundary>
|
||||||
|
{/* 顶部标题与导航栏 */}
|
||||||
|
<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">
|
||||||
|
{/* Tab 与恢复按钮同行 */}
|
||||||
|
<div className="flex items-end justify-between border-b-0">
|
||||||
|
<div className="flex-1 flex">
|
||||||
|
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={(e) => handleWindowTypeChange(e, type)}
|
||||||
|
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
|
||||||
|
windowType === type
|
||||||
|
? 'text-primary border-b-2 border-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleRestoreDefaults}
|
||||||
|
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
|
||||||
|
title="恢复当前模式默认"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主内容区域 */}
|
||||||
|
<div className="flex-1 p-4 sm:p-8">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{pageOrder.map((key, index, array) => {
|
||||||
|
const isChecked = visiblePages.includes(key);
|
||||||
|
const isDisabled = isChecked && visiblePages.length === 1;
|
||||||
|
return (
|
||||||
|
<SortableFeatureRow
|
||||||
|
key={key}
|
||||||
|
pageKey={key}
|
||||||
|
isLast={index === array.length - 1}
|
||||||
|
isChecked={isChecked}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onToggle={handlePageToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import App from '../App';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import {
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
getFeatureByKey,
|
||||||
|
} from '@/config/features';
|
||||||
|
|
||||||
|
// Mock storageUtil
|
||||||
|
vi.mock('@/utils/chromeStorage', () => ({
|
||||||
|
storageUtil: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(() => Promise.resolve()),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock i18next
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Options App', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('合法数据应正常加载并显示正确的功能列表', async () => {
|
||||||
|
const defaultOrder = getDefaultPageOrder();
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||||
|
Promise.resolve(defaultValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证默认可见的功能都被渲染出来了
|
||||||
|
for (const key of defaultOrder) {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (feature) {
|
||||||
|
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法 visiblePages 数据应回退到默认值', async () => {
|
||||||
|
const defaultVisible = getDefaultVisibleFeatureKeys();
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||||
|
if (key.includes('VisiblePages')) {
|
||||||
|
return Promise.resolve(['invalidPage', 'anotherInvalid']);
|
||||||
|
}
|
||||||
|
return Promise.resolve(defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证默认值的功能仍然被渲染(非法数据被回退)
|
||||||
|
for (const key of defaultVisible) {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (feature && key !== 'dashboard') {
|
||||||
|
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非法 pageOrder 数据应回退到默认值', async () => {
|
||||||
|
const defaultOrder = getDefaultPageOrder();
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||||
|
if (key.includes('PageOrder')) {
|
||||||
|
return Promise.resolve(['notARealPage', 123, null]);
|
||||||
|
}
|
||||||
|
return Promise.resolve(defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证默认顺序的功能都被渲染
|
||||||
|
for (const key of defaultOrder) {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (feature) {
|
||||||
|
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非数组数据应回退到默认值', async () => {
|
||||||
|
const defaultOrder = getDefaultPageOrder();
|
||||||
|
|
||||||
|
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||||
|
if (key.includes('VisiblePages')) {
|
||||||
|
return Promise.resolve('not-an-array');
|
||||||
|
}
|
||||||
|
if (key.includes('PageOrder')) {
|
||||||
|
return Promise.resolve({ foo: 'bar' });
|
||||||
|
}
|
||||||
|
return Promise.resolve(defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 验证默认功能都被渲染
|
||||||
|
for (const key of defaultOrder) {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
if (feature) {
|
||||||
|
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Document</title>
|
<title>扩展设置 - Testing Tools</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
Hello Options Page
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import AppRoot from '@/providers/AppRoot';
|
||||||
|
import '@/i18n';
|
||||||
|
import '@/src/index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<AppRoot>
|
||||||
|
<App />
|
||||||
|
</AppRoot>,
|
||||||
|
);
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/* 隐藏页面滚动条 */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 隐藏body滚动条但允许滚动 */
|
|
||||||
body {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* 统一圆角变量 */
|
|
||||||
--radius: 8px;
|
|
||||||
/* 统一背景颜色变量 */
|
|
||||||
--bg-color: #fafafa;
|
|
||||||
/* 统一文字颜色变量 */
|
|
||||||
--text-color: #333333;
|
|
||||||
/* 统一按钮颜色变量 */
|
|
||||||
--btn-bg: #e0e0e0;
|
|
||||||
/* 统一按钮文字颜色变量 */
|
|
||||||
--btn-text: #333333;
|
|
||||||
--border: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
width: 400px;
|
|
||||||
min-height: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航容器 */
|
|
||||||
.nav-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航按钮 */
|
|
||||||
.nav-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--btn-bg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--btn-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button:hover {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button.active {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
+39
-80
@@ -1,88 +1,47 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
import { Box } from '@mui/material';
|
import TopBar from '@/components/TopBar';
|
||||||
import type { PageType } from '@/types/storage';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import TimestampPage from './pages/TimestampPage';
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
import { getEntryPointType } from '@/config/features';
|
||||||
import './App.css';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const handleOpenOptions = () => {
|
||||||
|
chrome.runtime.openOptionsPage().catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
const PAGE_CONFIG = {
|
const entryType = useMemo(() => getEntryPointType(), []);
|
||||||
timestamp: { label: '时间戳', defaultVisible: true },
|
|
||||||
storageCleaner: { label: '存储清理', defaultVisible: true },
|
|
||||||
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
|
|
||||||
|
|
||||||
function App() {
|
const routerConfig = useMemo(() => {
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
if (entryType === 'tab') {
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>(['timestamp', 'storageCleaner']);
|
return {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
syncKey: 'app/tabRoute' as const,
|
||||||
|
visiblePagesKey: 'app/tabVisiblePages' as const,
|
||||||
useEffect(() => {
|
pageOrderKey: 'app/tabPageOrder' as const,
|
||||||
loadInitialData();
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded) {
|
|
||||||
storageUtil.set('app/currentRoute', currentPage);
|
|
||||||
}
|
}
|
||||||
}, [currentPage, isLoaded]);
|
return {
|
||||||
|
syncKey: 'app/popupRoute' as const,
|
||||||
const loadInitialData = async () => {
|
visiblePagesKey: 'app/popupVisiblePages' as const,
|
||||||
try {
|
pageOrderKey: 'app/popupPageOrder' as const,
|
||||||
const [savedRoute, savedVisiblePages] = await Promise.all([
|
};
|
||||||
storageUtil.get('app/currentRoute', 'timestamp'),
|
}, [entryType]);
|
||||||
storageUtil.get('app/visiblePages', ['timestamp', 'storageCleaner'] as PageType[]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (savedRoute) {
|
|
||||||
setCurrentPage(savedRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedVisiblePages) {
|
|
||||||
setVisiblePages(savedVisiblePages);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load initial data:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: PageType) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavButton = ({ pageKey }: { pageKey: PageType }) => {
|
|
||||||
const config = PAGE_CONFIG[pageKey];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={pageKey}>
|
|
||||||
<button
|
|
||||||
className={currentPage === pageKey ? 'nav-button active' : 'nav-button'}
|
|
||||||
onClick={() => handlePageChange(pageKey)}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return <div className="app">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<RouterProvider
|
||||||
<Box className="nav-container">
|
syncKey={routerConfig.syncKey}
|
||||||
{(Object.keys(PAGE_CONFIG) as PageType[])
|
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||||
.filter((key) => visiblePages.includes(key))
|
pageOrderKey={routerConfig.pageOrderKey}
|
||||||
.map((key) => <NavButton key={key} pageKey={key} />)}
|
>
|
||||||
</Box>
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
</div>
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</SnackbarProvider>
|
||||||
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|||||||
@@ -3,8 +3,31 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Default Popup Title</title>
|
<title>Testing Tools - 标签页</title>
|
||||||
<meta name="manifest.type" content="browser_action" />
|
<meta name="manifest.type" content="browser_action" />
|
||||||
|
<style>
|
||||||
|
/* Force initial popup size before React hydration */
|
||||||
|
html, body {
|
||||||
|
width: 400px;
|
||||||
|
height: 600px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
/* Ensure full size for the root container */
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* If opened in a tab (mode=tab), reset the fixed size */
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import AppRoot from '@/providers/AppRoot';
|
||||||
|
import '@/i18n';
|
||||||
|
import '@/src/index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './style.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</AppRoot>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
FormControlLabel,
|
|
||||||
Alert,
|
|
||||||
Snackbar,
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
|
||||||
import {
|
|
||||||
getCurrentTab,
|
|
||||||
isRestrictedUrl,
|
|
||||||
clearStorage,
|
|
||||||
formatCleaningResult,
|
|
||||||
} from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load tab info and user preferences
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInfo = async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.url) {
|
|
||||||
setError('无法获取当前标签页');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestrictedUrl(tab.url)) {
|
|
||||||
setError('存储清理功能不支持此页面');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDomain(new URL(tab.url).hostname);
|
|
||||||
|
|
||||||
// Load user preferences
|
|
||||||
const prefs = await storageUtil.get(
|
|
||||||
'storageCleaner/preferences',
|
|
||||||
DEFAULT_PREFERENCES,
|
|
||||||
);
|
|
||||||
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
|
||||||
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
|
||||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClean = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.id || !tab.url) {
|
|
||||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
|
||||||
setResult(cleaningResult);
|
|
||||||
|
|
||||||
// Save user preferences
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh,
|
|
||||||
selectedTypes: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto refresh if enabled
|
|
||||||
if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id!);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowConfirm(false);
|
|
||||||
}
|
|
||||||
}, [options, autoRefresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
if (tab?.id !== undefined) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id!);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
<Alert severity="error" icon={<WarningIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
当前页面: {domain || '加载中...'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Storage Type Options */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
|
||||||
选择要清理的存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.localStorage}
|
|
||||||
onChange={() => handleOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="localStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
onChange={() => handleOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="sessionStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.indexedDB}
|
|
||||||
onChange={() => handleOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="IndexedDB"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cookies}
|
|
||||||
onChange={() => handleOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cookies"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
onChange={() => handleOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cache Storage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
onChange={() => handleOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Service Workers"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Auto Refresh Option */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="清理完成后自动刷新页面"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '清理中...' : '清理'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
|
||||||
{result && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
{!autoRefresh && result.success && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
{showConfirm && (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255,255,255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6">确认清理</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
|
||||||
将清理以下存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
{options.localStorage && (
|
|
||||||
<Typography variant="body2">- localStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.sessionStorage && (
|
|
||||||
<Typography variant="body2">- sessionStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
|
||||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
|
||||||
{options.cacheStorage && (
|
|
||||||
<Typography variant="body2">- Cache Storage</Typography>
|
|
||||||
)}
|
|
||||||
{options.serviceWorkers && (
|
|
||||||
<Typography variant="body2">- Service Workers</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textAlign: 'center', mb: 1 }}
|
|
||||||
>
|
|
||||||
此操作不可撤销。
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" color="error" onClick={handleClean}>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert severity="info" variant="filled">
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
TextField,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
Snackbar,
|
|
||||||
Alert,
|
|
||||||
InputAdornment,
|
|
||||||
alpha,
|
|
||||||
Tooltip,
|
|
||||||
Theme,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
|
|
||||||
// ================= 常量配置 =================
|
|
||||||
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
|
||||||
const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
|
||||||
|
|
||||||
type UnitType = 'ms' | 's';
|
|
||||||
type ZoneType = (typeof ZONES)[number];
|
|
||||||
|
|
||||||
const INPUT_STYLE = {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'& fieldset': { border: 'none' },
|
|
||||||
'&:hover': { bgcolor: 'grey.100' },
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
|
|
||||||
},
|
|
||||||
'&.Mui-error': {
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ================= 子组件:实时时钟 =================
|
|
||||||
interface LiveClockProps {
|
|
||||||
unit: UnitType;
|
|
||||||
onCopy: (val: string) => void;
|
|
||||||
onUseNow: (val: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveClock = React.memo(({
|
|
||||||
unit,
|
|
||||||
onCopy,
|
|
||||||
onUseNow
|
|
||||||
}: LiveClockProps) => {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayVal = useMemo(() =>
|
|
||||||
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
[now, unit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
|
|
||||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
|
|
||||||
{displayVal}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
|
|
||||||
{unit}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="填充到下方">
|
|
||||||
<IconButton
|
|
||||||
aria-label="use current time"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onUseNow(now)}
|
|
||||||
sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }}
|
|
||||||
>
|
|
||||||
<AccessTimeIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="复制当前时间戳">
|
|
||||||
<IconButton
|
|
||||||
aria-label="copy current timestamp"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onCopy(displayVal)}
|
|
||||||
sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
|
|
||||||
>
|
|
||||||
<ContentCopyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
// ================= 子组件:多维度结果展示 =================
|
|
||||||
interface ResultViewProps {
|
|
||||||
result: string;
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
onCopy: (val: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultView = React.memo(({
|
|
||||||
result,
|
|
||||||
mode,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
onCopy
|
|
||||||
}: ResultViewProps) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
|
||||||
onCopy(result);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
}, [onCopy, result]);
|
|
||||||
|
|
||||||
const extraInfo = useMemo(() => {
|
|
||||||
if (!result) return null;
|
|
||||||
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
relative: d.fromNow(),
|
|
||||||
iso: d.toISOString(),
|
|
||||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
||||||
};
|
|
||||||
}, [result, mode, zone, unit]);
|
|
||||||
|
|
||||||
if (!result) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
|
|
||||||
animation: 'fadeIn 0.3s ease-out',
|
|
||||||
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
|
|
||||||
}}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled', mb: 1, display: 'block', ml: 1, fontWeight: 500 }}>
|
|
||||||
转换结果
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
value={result}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton
|
|
||||||
aria-label="copy result"
|
|
||||||
size="small"
|
|
||||||
onClick={handleCopy}
|
|
||||||
sx={{
|
|
||||||
color: copied ? 'success.main' : 'primary.main',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
||||||
transform: copied ? 'scale(1.2)' : 'scale(1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
...INPUT_STYLE,
|
|
||||||
mb: 2,
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
...INPUT_STYLE['& .MuiOutlinedInput-root'],
|
|
||||||
bgcolor: alpha('#2563eb', 0.03),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 辅助信息预览 */}
|
|
||||||
<Stack spacing={1} sx={{ px: 1 }}>
|
|
||||||
{[
|
|
||||||
{ label: '相对时间', value: extraInfo?.relative },
|
|
||||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
|
||||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
|
||||||
].map((item) => (
|
|
||||||
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
onClick={() => { if (item.value) onCopy(item.value); }}
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'text.primary',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { color: 'primary.main', textDecoration: 'underline' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
|
||||||
|
|
||||||
// ================= 主页面组件 =================
|
|
||||||
export default function TimestampPage() {
|
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
|
||||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
|
||||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
|
||||||
const [unit, setUnit] = useState<UnitType>('ms');
|
|
||||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
|
||||||
const [result, setResult] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
|
|
||||||
|
|
||||||
const copy = useCallback(async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setSnack({ open: true, msg: '已复制' });
|
|
||||||
} catch {
|
|
||||||
setSnack({ open: true, msg: '复制失败' });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const convert = useCallback(() => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
const rawInput = tsInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const num = Number(rawInput);
|
|
||||||
if (isNaN(num)) { setError('无效数字'); return; }
|
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
|
||||||
if (!d.isValid()) { setError('无效时间戳'); return; }
|
|
||||||
setError('');
|
|
||||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
|
||||||
} else {
|
|
||||||
const rawInput = dtInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
|
||||||
if (!d.isValid()) { setError('格式错误'); return; }
|
|
||||||
setError('');
|
|
||||||
const ms = d.valueOf();
|
|
||||||
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
|
||||||
}
|
|
||||||
}, [mode, tsInput, dtInput, unit, zone]);
|
|
||||||
|
|
||||||
// 智能实时转换 (Debounce Effect)
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
convert();
|
|
||||||
}, 400);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [convert]);
|
|
||||||
|
|
||||||
const handleUseNow = useCallback((now: number) => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
|
||||||
} else {
|
|
||||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
|
||||||
}
|
|
||||||
}, [mode, unit, zone]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2.5,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 1. 实时时钟 */}
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
|
|
||||||
<Tooltip title="切换单位">
|
|
||||||
<IconButton
|
|
||||||
aria-label="switch unit"
|
|
||||||
size="small"
|
|
||||||
onClick={() => { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', right: 80, top: 4, color: 'grey.400',
|
|
||||||
transition: 'transform 0.3s ease',
|
|
||||||
'&:hover': { transform: 'rotate(180deg)', color: 'primary.main' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SwapHorizIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 2. 模式切换 */}
|
|
||||||
<Box sx={{ position: 'relative', display: 'flex', p: 0.5, bgcolor: 'grey.100', borderRadius: 3.5, mb: 3, overflow: 'hidden' }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', height: 'calc(100% - 8px)', width: 'calc(50% - 4px)',
|
|
||||||
bgcolor: '#fff', borderRadius: 3, boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
|
||||||
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
|
||||||
top: 4, left: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
|
||||||
<Button
|
|
||||||
key={m} fullWidth disableRipple
|
|
||||||
onClick={() => { setMode(m); setError(''); setResult(''); }}
|
|
||||||
sx={{
|
|
||||||
position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
|
|
||||||
fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
|
|
||||||
color: mode === m ? 'text.primary' : 'text.disabled',
|
|
||||||
'&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 3. 输入与设置 */}
|
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
|
||||||
<TextField
|
|
||||||
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
|
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(val);
|
|
||||||
} else {
|
|
||||||
setDtInput(val);
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error}
|
|
||||||
fullWidth
|
|
||||||
sx={INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
<Select
|
|
||||||
fullWidth value={unit}
|
|
||||||
onChange={(e) => { setUnit(e.target.value as UnitType); }}
|
|
||||||
sx={{ ...INPUT_STYLE, flex: 1 }}
|
|
||||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
|
||||||
>
|
|
||||||
<MenuItem value="ms">毫秒 (ms)</MenuItem>
|
|
||||||
<MenuItem value="s">秒 (s)</MenuItem>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
fullWidth value={zone}
|
|
||||||
onChange={(e) => { setZone(e.target.value as ZoneType); }}
|
|
||||||
sx={{ ...INPUT_STYLE, flex: 1.5 }}
|
|
||||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
|
||||||
>
|
|
||||||
{ZONES.map((z) => (
|
|
||||||
<MenuItem key={z} value={z}>{z}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* 4. 转换操作 (作为手动确认) */}
|
|
||||||
<Button
|
|
||||||
fullWidth variant="contained" disableElevation disableRipple
|
|
||||||
onClick={convert}
|
|
||||||
sx={{
|
|
||||||
py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
|
|
||||||
bgcolor: 'primary.main', transition: 'all 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
|
|
||||||
'&:active': { transform: 'translateY(0)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
立即转换
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 5. 结果展示 */}
|
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={snack.open} autoHideDuration={1500}
|
|
||||||
onClose={() => { setSnack((s) => ({ ...s, open: false })); }}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="success" variant="filled" icon={false} sx={{ borderRadius: 2.5, bgcolor: 'grey.900' }}>
|
|
||||||
{snack.msg}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
|
||||||
monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-size: inherit; /* 先全部重置为继承大小 */
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
|
import TopBar from '@/components/TopBar';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const handleOpenOptions = () => {
|
||||||
|
chrome.runtime.openOptionsPage().catch((err) => {
|
||||||
|
console.error('Failed to open options page:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||||
|
return () => {
|
||||||
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
|
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||||
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</SnackbarProvider>
|
||||||
|
</RouterProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Testing Tools - Side Panel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import AppRoot from '@/providers/AppRoot';
|
||||||
|
import '@/i18n';
|
||||||
|
import '@/src/index.css';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<AppRoot>
|
||||||
|
<App />
|
||||||
|
</AppRoot>,
|
||||||
|
);
|
||||||
+53
-8
@@ -1,12 +1,32 @@
|
|||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import * as reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import * as reactPlugin from 'eslint-plugin-react';
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] },
|
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
|
||||||
|
{
|
||||||
|
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
|
// 3. 针对测试文件专属沙箱:解耦强类型死锁,放行 any,容忍未消费变量
|
||||||
|
{
|
||||||
|
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 4. 核心业务全受控大管线(Hooks, Entrypoints, Components 统一护航)
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'hooks/**/*.{ts,tsx}',
|
'hooks/**/*.{ts,tsx}',
|
||||||
@@ -16,29 +36,54 @@ export default tseslint.config(
|
|||||||
'components/**/*.{ts,tsx}',
|
'components/**/*.{ts,tsx}',
|
||||||
'services/**/*.{ts,tsx}',
|
'services/**/*.{ts,tsx}',
|
||||||
],
|
],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
},
|
},
|
||||||
|
// 💡 修复点 1(史诗级治愈):废除脆弱的 project 硬编码路径!
|
||||||
|
// 拥抱 typescript-eslint 官方推荐的 projectService 常驻动态类型调度中枢。
|
||||||
|
// 它会在内存中全自动、流式为所有新建、悬空或暂存文件分配编译上下文,
|
||||||
|
// 彻底终结 "file is not included in any tsconfig" 的全量崩溃黑洞!
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ['./tsconfig.json'],
|
projectService: true,
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 挂载插件沙箱
|
||||||
plugins: {
|
plugins: {
|
||||||
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了
|
react: reactPlugin,
|
||||||
'react-hooks': reactHooks as any,
|
'react-hooks': reactHooks,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
|
||||||
rules: {
|
rules: {
|
||||||
|
// 激活 react-hooks 官方推荐规则
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
// 激活 react 官方精选规则(排除旧版 React 必须手动 import 的历史包袱)
|
||||||
|
...reactPlugin.configs.recommended.rules,
|
||||||
|
...reactPlugin.configs['jsx-runtime'].rules,
|
||||||
|
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
|
||||||
|
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 彻底关闭老旧的 JSX 作用域检查,全面契合 React 19 核心美学
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import type { CustomDetector } from 'i18next-browser-languagedetector'; // 💡 1. 引入官方强类型探测器接口
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// 导入 Day.js 本地化语言包
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
// 同步加载全局核心命名空间
|
||||||
|
import commonZh from './locales/zh/common.json';
|
||||||
|
import featuresZh from './locales/zh/features.json';
|
||||||
|
import commonEn from './locales/en/common.json';
|
||||||
|
import featuresEn from './locales/en/features.json';
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
zh: {
|
||||||
|
common: commonZh,
|
||||||
|
features: featuresZh,
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
common: commonEn,
|
||||||
|
features: featuresEn,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const;
|
||||||
|
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||||
|
|
||||||
|
const LANGUAGE_STORAGE_KEY = 'app/language';
|
||||||
|
const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将任意语言标识归一化为受支持的核心代码
|
||||||
|
*/
|
||||||
|
export const normalizeLanguage = (lng: string): SupportedLanguage => {
|
||||||
|
if (!lng) return 'en';
|
||||||
|
return lng.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格校验语言安全边界
|
||||||
|
*/
|
||||||
|
const isValidLanguage = (lng: unknown): lng is SupportedLanguage => {
|
||||||
|
return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步从 localStorage 获取语言快照(消除异步闪烁)
|
||||||
|
*/
|
||||||
|
const getSyncLanguageSnapshot = (): SupportedLanguage | null => {
|
||||||
|
try {
|
||||||
|
const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY);
|
||||||
|
if (!val) return null;
|
||||||
|
const parsed = JSON.parse(val) as unknown;
|
||||||
|
return isValidLanguage(parsed) ? parsed : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[i18n] Failed to parse sync language snapshot from localStorage:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 💡 2. 强类型接口重塑:显式绑定 CustomDetector 类型,
|
||||||
|
// 告诉 TS 编译器这些方法将被全局 Languagedetector 框架隐式调用,彻底治愈“未使用函数”报错!
|
||||||
|
const chromeStorageDetector: CustomDetector = {
|
||||||
|
name: 'chromeStorage',
|
||||||
|
lookup() {
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
cacheUserLanguage(lng: string) {
|
||||||
|
const target = normalizeLanguage(lng);
|
||||||
|
// 💡 修复点:对异步写盘操作追加 void 算子或 catch,吞掉 Promise 被忽略警告
|
||||||
|
storageUtil.set(LANGUAGE_STORAGE_KEY, target).catch((err) => {
|
||||||
|
console.error('[i18n Detector Error] Failed to write back language state:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const detector = new LanguageDetector();
|
||||||
|
detector.addDetector(chromeStorageDetector);
|
||||||
|
|
||||||
|
const syncLng = getSyncLanguageSnapshot();
|
||||||
|
|
||||||
|
// 💡 3. 修复点:对 i18n.init() 返回的异步 Promise 前方追加 void 斩断依赖链,放行编译
|
||||||
|
void i18n
|
||||||
|
.use(detector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
fallbackLng: 'en',
|
||||||
|
lng: syncLng || undefined,
|
||||||
|
ns: ['common', 'features'],
|
||||||
|
defaultNS: 'common',
|
||||||
|
debug: false,
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
detection: {
|
||||||
|
order: ['chromeStorage', 'navigator'],
|
||||||
|
caches: ['chromeStorage'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听语言变更
|
||||||
|
i18n.on('languageChanged', (lng) => {
|
||||||
|
const normalizedLng = normalizeLanguage(lng);
|
||||||
|
dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en');
|
||||||
|
localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化时从长期异步存储中恢复校准
|
||||||
|
storageUtil
|
||||||
|
.get(LANGUAGE_STORAGE_KEY)
|
||||||
|
.then((lng) => {
|
||||||
|
const rawTargetLng = lng || syncLng;
|
||||||
|
|
||||||
|
if (!rawTargetLng) {
|
||||||
|
const initialLng = normalizeLanguage(i18n.language);
|
||||||
|
|
||||||
|
// 💡 修复点:对初始化同步写盘追加安全的 Promise .catch() 异常隔离防护罩
|
||||||
|
storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng).catch((err) => {
|
||||||
|
console.error('[i18n Init Error] Persistent sync collapsed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialLng !== normalizeLanguage(i18n.language)) {
|
||||||
|
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
|
||||||
|
void i18n.changeLanguage(initialLng);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLng = normalizeLanguage(String(rawTargetLng));
|
||||||
|
|
||||||
|
if (isValidLanguage(targetLng) && targetLng !== normalizeLanguage(i18n.language)) {
|
||||||
|
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
|
||||||
|
void i18n.changeLanguage(targetLng);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[i18n Context Error] Async local storage lookup collapsed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Base64 Converter",
|
||||||
|
"pageSubtitle": "Encode and decode text, files, and images with Base64",
|
||||||
|
"textMode": "Text",
|
||||||
|
"fileMode": "File",
|
||||||
|
"imageMode": "Image",
|
||||||
|
"encode": "Encode",
|
||||||
|
"decode": "Decode",
|
||||||
|
"clear": "Clear",
|
||||||
|
"textInputPlaceholder": "Enter text to encode to Base64...",
|
||||||
|
"base64InputPlaceholder": "Enter Base64 string to decode...",
|
||||||
|
"base64Output": "Base64 Output",
|
||||||
|
"textOutput": "Decoded Text Output",
|
||||||
|
"copyRaw": "Copy Raw Base64",
|
||||||
|
"copyDataUri": "Copy Data URI",
|
||||||
|
"clickOrDropToFile": "Click or drop a file here",
|
||||||
|
"clickOrDropToImage": "Click or drop an image here",
|
||||||
|
"clickOrDropToReplace": "Click or drop to replace the file",
|
||||||
|
"maxFileSize": "Maximum file size: {{max}}",
|
||||||
|
"supportedFormats": "Supports PNG, JPG, WEBP, GIF, BMP, SVG, etc.",
|
||||||
|
"fileSizeExceeded": "File size exceeds the limit (max {{max}})",
|
||||||
|
"unsupportedImageType": "Unsupported image format",
|
||||||
|
"conversionFailed": "Conversion failed",
|
||||||
|
"originalSize": "Original Size",
|
||||||
|
"encodedSize": "Encoded Size",
|
||||||
|
"invalidBase64": "Invalid Base64 string",
|
||||||
|
"binaryDataDetected": "Input appears to be binary data (e.g. an image). Please switch to the Image tab.",
|
||||||
|
"imageDataUriHint": "Detected an image data URI — please use the Image tab to decode it.",
|
||||||
|
"switchToImageMode": "Switch to Image mode",
|
||||||
|
"download": "Download",
|
||||||
|
"decodedFileName": "Decoded file name",
|
||||||
|
"decodeBase64Placeholder": "Enter Base64 or data URI to decode...",
|
||||||
|
"decodedFileOutput": "Decoded File",
|
||||||
|
"decodedImageOutput": "Decoded Image",
|
||||||
|
"inferredMimeType": "Inferred MIME type",
|
||||||
|
"decodedSize": "Decoded size"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"appName": "Testing Tools",
|
||||||
|
"buttons": {
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"copy": "Copy",
|
||||||
|
"clear": "Clear",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"toggleLanguage": "Switch Language",
|
||||||
|
"toggleTheme": "Toggle theme",
|
||||||
|
"themeMode": {
|
||||||
|
"light": "Switch to dark mode",
|
||||||
|
"dark": "Switch to system mode",
|
||||||
|
"system": "Switch to light mode"
|
||||||
|
},
|
||||||
|
"search": "Search tools...",
|
||||||
|
"back": "Back",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"recentSearch": "Recent search",
|
||||||
|
"noResults": "No tools found",
|
||||||
|
"openInTab": "Open in tab",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"copySuccess": "Copied to clipboard",
|
||||||
|
"copyError": "Copy failed"
|
||||||
|
},
|
||||||
|
"textInputArea": {
|
||||||
|
"clear": "Clear",
|
||||||
|
"copyContent": "Copy content",
|
||||||
|
"cleared": "Cleared",
|
||||||
|
"placeholder": "placeholder text"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "Dashboard"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"title": "Timestamp",
|
||||||
|
"description": "Unix millisecond conversion and formatting"
|
||||||
|
},
|
||||||
|
"storageCleaner": {
|
||||||
|
"title": "Storage Cleaner",
|
||||||
|
"description": "Clean cache, cookies, and local storage"
|
||||||
|
},
|
||||||
|
"qrCode": {
|
||||||
|
"title": "QR Code Tools",
|
||||||
|
"description": "Generate QR code for the selected URL"
|
||||||
|
},
|
||||||
|
"textStatistics": {
|
||||||
|
"title": "Text Statistics",
|
||||||
|
"description": "Real-time analysis of text characters, words, and bytes"
|
||||||
|
},
|
||||||
|
"jwt": {
|
||||||
|
"title": "JWT Parser",
|
||||||
|
"description": "JSON Web Token decoding and viewing"
|
||||||
|
},
|
||||||
|
"jsonDiff": {
|
||||||
|
"title": "JSON Tools",
|
||||||
|
"description": "Diff, format, YAML/TOML conversion, and minify"
|
||||||
|
},
|
||||||
|
"base64Converter": {
|
||||||
|
"title": "Base64 Converter",
|
||||||
|
"description": "Encode text, files, and images to Base64"
|
||||||
|
},
|
||||||
|
"markdownToHtml": {
|
||||||
|
"title": "Markdown to HTML",
|
||||||
|
"description": "Real-time Markdown conversion and HTML preview"
|
||||||
|
},
|
||||||
|
"htmlToMarkdown": {
|
||||||
|
"title": "HTML to Markdown",
|
||||||
|
"description": "Real-time HTML conversion and Markdown preview"
|
||||||
|
},
|
||||||
|
"rightClickRestorer": {
|
||||||
|
"title": "Right Click Restorer",
|
||||||
|
"description": "Detect and restore disabled browser right-click menus"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "HTML to Markdown",
|
||||||
|
"pageSubtitle": "Convert HTML to Markdown in real-time",
|
||||||
|
"splitMode": "Split",
|
||||||
|
"previewMode": "Preview",
|
||||||
|
"markdownMode": "Markdown",
|
||||||
|
"clear": "Clear",
|
||||||
|
"inputLabel": "HTML Input",
|
||||||
|
"previewLabel": "Markdown Preview",
|
||||||
|
"markdownOutputLabel": "Markdown Output",
|
||||||
|
"inputPlaceholder": "Enter your HTML content here...",
|
||||||
|
"charCount": "{{count}} chars",
|
||||||
|
"copyMarkdown": "Copy Markdown",
|
||||||
|
"download": "Download",
|
||||||
|
"emptyHint": "Enter HTML content to see the conversion result"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "JSON Diff",
|
||||||
|
"pageSubtitle": "Compare differences between two JSON values",
|
||||||
|
"leftPlaceholder": "Enter the original JSON...",
|
||||||
|
"rightPlaceholder": "Enter the target JSON...",
|
||||||
|
"leftLabel": "Original JSON",
|
||||||
|
"rightLabel": "Target JSON",
|
||||||
|
"compareButton": "Compare",
|
||||||
|
"clearButton": "Clear",
|
||||||
|
"sideBySideMode": "Side by side",
|
||||||
|
"unifiedMode": "Unified",
|
||||||
|
"previousDiff": "Previous",
|
||||||
|
"nextDiff": "Next",
|
||||||
|
"noDiffs": "No differences",
|
||||||
|
"diffCount": "{{count}} differences",
|
||||||
|
"invalidJson": "Invalid JSON format",
|
||||||
|
"emptyHint": "Enter JSON on both sides and click Compare",
|
||||||
|
"added": "Added",
|
||||||
|
"removed": "Removed",
|
||||||
|
"modified": "Modified"
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"formatTitle": "JSON Formatter",
|
||||||
|
"formatSubtitle": "Beautify and format JSON data",
|
||||||
|
"inputPlaceholder": "Enter JSON to format...",
|
||||||
|
"formatButton": "Format",
|
||||||
|
"clearButton": "Clear",
|
||||||
|
"sortKeys": "Sort Keys",
|
||||||
|
"sortKeysTooltip": "Sort JSON object keys in alphabetical order",
|
||||||
|
"indentSize": "Indent",
|
||||||
|
"indentSpaces": "{{count}} spaces",
|
||||||
|
"outputLabel": "Formatted Result",
|
||||||
|
"copySuccess": "Copied",
|
||||||
|
"copyFail": "Copy failed",
|
||||||
|
"noContent": "Nothing to copy",
|
||||||
|
"invalidJson": "Invalid JSON format",
|
||||||
|
"emptyHint": "Enter JSON and click Format",
|
||||||
|
"originalSize": "Original size",
|
||||||
|
"formattedSize": "Formatted size",
|
||||||
|
"diffMode": "Diff",
|
||||||
|
"formatMode": "Format",
|
||||||
|
"yamlMode": "YAML",
|
||||||
|
"tomlMode": "TOML",
|
||||||
|
"minifyMode": "Minify",
|
||||||
|
"yamlTitle": "JSON to YAML",
|
||||||
|
"yamlSubtitle": "Convert JSON data to YAML format",
|
||||||
|
"yamlModeInputPlaceholder": "Enter JSON to convert...",
|
||||||
|
"yamlModeOutputLabel": "YAML Result",
|
||||||
|
"yamlModeEmptyHint": "Enter JSON and click Convert",
|
||||||
|
"convertButton": "Convert",
|
||||||
|
"tomlTitle": "JSON to TOML",
|
||||||
|
"tomlSubtitle": "Convert JSON data to TOML format",
|
||||||
|
"tomlModeInputPlaceholder": "Enter JSON to convert...",
|
||||||
|
"tomlModeOutputLabel": "TOML Result",
|
||||||
|
"tomlModeEmptyHint": "Enter JSON and click Convert",
|
||||||
|
"minifyTitle": "JSON Minifier",
|
||||||
|
"minifySubtitle": "Compress JSON into a compact single-line format",
|
||||||
|
"minifyModeInputPlaceholder": "Enter JSON to minify...",
|
||||||
|
"minifyModeOutputLabel": "Minified Result",
|
||||||
|
"minifyModeEmptyHint": "Enter JSON and click Minify",
|
||||||
|
"minifyButton": "Minify"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "JWT Parser",
|
||||||
|
"pageSubtitle": "JSON Web Token decoding and viewing",
|
||||||
|
"placeholder": "Paste Encoded JWT here...",
|
||||||
|
"headerTitle": "HEADER: Algorithm & Token Type",
|
||||||
|
"payloadTitle": "PAYLOAD: Data",
|
||||||
|
"signatureTitle": "Signature",
|
||||||
|
"noSignature": "No Signature",
|
||||||
|
"invalidFormat": "Unable to parse",
|
||||||
|
"errors": {
|
||||||
|
"invalidBase64String": "Invalid base64url string",
|
||||||
|
"failedToDecode": "Failed to decode base64url: ",
|
||||||
|
"invalidFormat": "Invalid JWT format: expected 3 parts separated by .",
|
||||||
|
"parseHeaderFailed": "Failed to parse Header: ",
|
||||||
|
"parsePayloadFailed": "Failed to parse Payload: "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Markdown to HTML",
|
||||||
|
"pageSubtitle": "Convert Markdown to HTML in real-time with preview",
|
||||||
|
"splitMode": "Split",
|
||||||
|
"previewMode": "Preview",
|
||||||
|
"htmlMode": "HTML",
|
||||||
|
"clear": "Clear",
|
||||||
|
"print": "Print",
|
||||||
|
"download": "Download",
|
||||||
|
"inputLabel": "Markdown Input",
|
||||||
|
"previewLabel": "Live Preview",
|
||||||
|
"htmlOutputLabel": "HTML Output",
|
||||||
|
"inputPlaceholder": "Enter your Markdown content here...",
|
||||||
|
"charCount": "{{count}} chars",
|
||||||
|
"copyHtml": "Copy HTML",
|
||||||
|
"downloadSuccess": "File downloaded successfully",
|
||||||
|
"printSuccess": "Print window opened"
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "QR Code Tools",
|
||||||
|
"pageSubtitle": "Generate and parse QR codes",
|
||||||
|
|
||||||
|
"generateMode": "Generate QR Code",
|
||||||
|
"parseMode": "Parse QR Code",
|
||||||
|
|
||||||
|
"urlToQr": "Text to QR Code",
|
||||||
|
"qrToUrl": "QR Code to Text",
|
||||||
|
|
||||||
|
"urlInputLabel": "Enter URL or Text",
|
||||||
|
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
|
||||||
|
"generateButton": "Generate QR Code",
|
||||||
|
"generating": "Generating...",
|
||||||
|
|
||||||
|
"qrCodeWillShow": "QR code will be shown here",
|
||||||
|
"downloadButton": "Download QR Code",
|
||||||
|
"copyQrButton": "Copy QR Code Image",
|
||||||
|
|
||||||
|
"qrCodeSuccess": "QR code generated successfully",
|
||||||
|
"qrCodeDownloadSuccess": "QR code downloaded successfully",
|
||||||
|
"qrCodeCopySuccess": "QR code copied to clipboard",
|
||||||
|
|
||||||
|
"selectImage": "Please select a QR code image",
|
||||||
|
"parseSuccess": "QR code parsed successfully",
|
||||||
|
"noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
|
||||||
|
"parseError": "Failed to parse QR code, please try again",
|
||||||
|
"generateError": "Failed to generate QR code, please check the input",
|
||||||
|
"copyError": "Copy failed, please try again",
|
||||||
|
|
||||||
|
"imagePasted": "Image pasted, parsing...",
|
||||||
|
"imagePasteError": "Failed to paste image, please try again",
|
||||||
|
"imageCleared": "Image cleared",
|
||||||
|
"clickToUpload": "Click, drag, or paste to upload QR code image",
|
||||||
|
"supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
|
||||||
|
|
||||||
|
"parseButton": "Parse QR Code",
|
||||||
|
"parsing": "Parsing...",
|
||||||
|
"resultLabel": "Parsing Result",
|
||||||
|
"copyTooltip": "Copy",
|
||||||
|
|
||||||
|
"enterUrlError": "Please enter URL or text",
|
||||||
|
"clickToChange": "Click to change image",
|
||||||
|
|
||||||
|
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
|
||||||
|
"autoGenerateHint": "QR code will be generated automatically after input",
|
||||||
|
"autoParseHint": "QR code will be parsed automatically after upload"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Storage Cleaner",
|
||||||
|
"pageSubtitle": "Clear cache, cookies, and local storage",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"occupied": "Occupied {{size}}",
|
||||||
|
"cleaning": "Cleaning...",
|
||||||
|
"cleanNow": "Clean Now",
|
||||||
|
"autoRefresh": "Auto refresh page after cleaning",
|
||||||
|
"selectAll": "Select all items",
|
||||||
|
"cleanSuccess": "Cleaning complete",
|
||||||
|
"cleanError": "Cleaning failed",
|
||||||
|
"noData": "No data",
|
||||||
|
"countUnit": "items",
|
||||||
|
"errorNoTab": "Unable to get current tab",
|
||||||
|
"errorRestricted": "Storage cleaning is not supported on this page",
|
||||||
|
"cleanSuccessReload": "Cleaning successful, reloading page...",
|
||||||
|
"errorStandardOnly": "Storage cleaning only works on standard web pages",
|
||||||
|
"confirmTitle": "Confirm Clear Data?",
|
||||||
|
"confirmDesc": "You are about to permanently delete the following selected storage items from the current page.",
|
||||||
|
"irreversible": "This action is irreversible",
|
||||||
|
"confirmAction": "Confirm Clear",
|
||||||
|
"cleanedSummary": "Cleaned {{items}}",
|
||||||
|
"noDataToClean": "No storage data found to clean",
|
||||||
|
"partialFailure": "Some items failed to clean",
|
||||||
|
"options": {
|
||||||
|
"localStorage": "Local Storage",
|
||||||
|
"sessionStorage": "Session Storage",
|
||||||
|
"indexedDB": "IndexedDB",
|
||||||
|
"cookies": "Cookies",
|
||||||
|
"cacheStorage": "Cache Storage",
|
||||||
|
"serviceWorkers": "Service Workers"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Text Statistics",
|
||||||
|
"pageSubtitle": "Real-time analysis of characters, words, lines, and byte size",
|
||||||
|
"placeholder": "Type or paste text here...",
|
||||||
|
"characters": "Characters",
|
||||||
|
"words": "Words",
|
||||||
|
"lines": "Lines",
|
||||||
|
"bytes": "Byte Size"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Timestamp Conversion",
|
||||||
|
"pageSubtitle": "Unix millisecond conversion and formatting",
|
||||||
|
"tsToDate": "Timestamp → Date",
|
||||||
|
"dateToTs": "Date → Timestamp",
|
||||||
|
"placeholderTs": "Enter timestamp...",
|
||||||
|
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
|
||||||
|
"unitMs": "Millisecond (ms)",
|
||||||
|
"unitS": "Second (s)",
|
||||||
|
"convertButton": "Convert Now",
|
||||||
|
"currentTs": "Current Timestamp",
|
||||||
|
"useNowTooltip": "Use this value",
|
||||||
|
"copyTsTooltip": "Copy timestamp",
|
||||||
|
"usedSuccess": "Using current timestamp",
|
||||||
|
"resultLabel": "Conversion Result",
|
||||||
|
"copyResultTooltip": "Copy result",
|
||||||
|
"relativeTime": "Relative Time",
|
||||||
|
"iso8601": "ISO 8601",
|
||||||
|
"utcTime": "UTC Time",
|
||||||
|
"copyTooltip": "Copy",
|
||||||
|
"resultEmpty": "Enter a value and click convert",
|
||||||
|
"errors": {
|
||||||
|
"invalidNumber": "Invalid number",
|
||||||
|
"invalidTimestamp": "Invalid timestamp",
|
||||||
|
"invalidFormat": "Format error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Base64 转换器",
|
||||||
|
"pageSubtitle": "文本、文件与图像的 Base64 编码与解码",
|
||||||
|
"textMode": "文本",
|
||||||
|
"fileMode": "文件",
|
||||||
|
"imageMode": "图像",
|
||||||
|
"encode": "编码",
|
||||||
|
"decode": "解码",
|
||||||
|
"clear": "清空",
|
||||||
|
"textInputPlaceholder": "输入需要编码为 Base64 的文本...",
|
||||||
|
"base64InputPlaceholder": "输入需要解码的 Base64 字符串...",
|
||||||
|
"base64Output": "Base64 编码结果",
|
||||||
|
"textOutput": "解码文本结果",
|
||||||
|
"copyRaw": "复制纯 Base64",
|
||||||
|
"copyDataUri": "复制 Data URI",
|
||||||
|
"clickOrDropToFile": "点击或拖拽文件到此处",
|
||||||
|
"clickOrDropToImage": "点击或拖拽图像到此处",
|
||||||
|
"clickOrDropToReplace": "点击或拖拽以替换文件",
|
||||||
|
"maxFileSize": "最大文件大小:{{max}}",
|
||||||
|
"supportedFormats": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
|
||||||
|
"fileSizeExceeded": "文件大小超出限制(最大 {{max}})",
|
||||||
|
"unsupportedImageType": "不支持的图像格式",
|
||||||
|
"conversionFailed": "转换失败",
|
||||||
|
"originalSize": "原始大小",
|
||||||
|
"encodedSize": "编码大小",
|
||||||
|
"invalidBase64": "Base64 字符串无效",
|
||||||
|
"binaryDataDetected": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
|
||||||
|
"imageDataUriHint": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
|
||||||
|
"switchToImageMode": "切换到图像模式",
|
||||||
|
"download": "下载",
|
||||||
|
"decodedFileName": "解码后文件名",
|
||||||
|
"decodeBase64Placeholder": "输入需要解码的 Base64 或 data URI...",
|
||||||
|
"decodedFileOutput": "解码文件",
|
||||||
|
"decodedImageOutput": "解码图像",
|
||||||
|
"inferredMimeType": "推断的 MIME 类型",
|
||||||
|
"decodedSize": "解码大小"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"appName": "测试工具",
|
||||||
|
"buttons": {
|
||||||
|
"save": "保存",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"copy": "复制",
|
||||||
|
"clear": "清理",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"toggleLanguage": "切换语言",
|
||||||
|
"toggleTheme": "切换主题",
|
||||||
|
"themeMode": {
|
||||||
|
"light": "切换到深色模式",
|
||||||
|
"dark": "切换到系统模式",
|
||||||
|
"system": "切换到浅色模式"
|
||||||
|
},
|
||||||
|
"search": "搜索工具...",
|
||||||
|
"back": "返回",
|
||||||
|
"clearSearch": "清除搜索",
|
||||||
|
"recentSearch": "最近搜索",
|
||||||
|
"noResults": "未找到相关工具",
|
||||||
|
"openInTab": "在标签页打开",
|
||||||
|
"settings": "设置"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"copySuccess": "已复制到剪贴板",
|
||||||
|
"copyError": "复制失败"
|
||||||
|
},
|
||||||
|
"textInputArea": {
|
||||||
|
"clear": "清空",
|
||||||
|
"copyContent": "复制内容",
|
||||||
|
"cleared": "已清空",
|
||||||
|
"placeholder": "请输入文本"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"dashboard": {
|
||||||
|
"title": "仪表盘"
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"title": "时间戳",
|
||||||
|
"description": "Unix 毫秒数转换与格式化"
|
||||||
|
},
|
||||||
|
"storageCleaner": {
|
||||||
|
"title": "存储清理",
|
||||||
|
"description": "清理缓存、Cookies 及本地存储"
|
||||||
|
},
|
||||||
|
"qrCode": {
|
||||||
|
"title": "二维码工具",
|
||||||
|
"description": "生成当前选中的 URL 的二维码"
|
||||||
|
},
|
||||||
|
"textStatistics": {
|
||||||
|
"title": "文本统计",
|
||||||
|
"description": "实时分析文本字符、单词及字节"
|
||||||
|
},
|
||||||
|
"jwt": {
|
||||||
|
"title": "JWT 解析",
|
||||||
|
"description": "JSON Web Token 解码与查看"
|
||||||
|
},
|
||||||
|
"jsonDiff": {
|
||||||
|
"title": "JSON 工具",
|
||||||
|
"description": "差异比较、格式化、YAML/TOML 转换及压缩"
|
||||||
|
},
|
||||||
|
"base64Converter": {
|
||||||
|
"title": "Base64 转换器",
|
||||||
|
"description": "文本、文件与图像的 Base64 编码转换"
|
||||||
|
},
|
||||||
|
"markdownToHtml": {
|
||||||
|
"title": "Markdown 转 HTML",
|
||||||
|
"description": "实时 Markdown 转换与 HTML 预览"
|
||||||
|
},
|
||||||
|
"htmlToMarkdown": {
|
||||||
|
"title": "HTML 转 Markdown",
|
||||||
|
"description": "实时 HTML 转换与 Markdown 预览"
|
||||||
|
},
|
||||||
|
"rightClickRestorer": {
|
||||||
|
"title": "右键恢复",
|
||||||
|
"description": "检测并恢复被网站禁用的浏览器右键菜单"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "HTML 转 Markdown",
|
||||||
|
"pageSubtitle": "实时将 HTML 转换为 Markdown 格式",
|
||||||
|
"splitMode": "分屏",
|
||||||
|
"previewMode": "预览",
|
||||||
|
"markdownMode": "Markdown",
|
||||||
|
"clear": "清空",
|
||||||
|
"inputLabel": "HTML 输入",
|
||||||
|
"previewLabel": "Markdown 预览",
|
||||||
|
"markdownOutputLabel": "Markdown 输出",
|
||||||
|
"inputPlaceholder": "在此输入 HTML 内容...",
|
||||||
|
"charCount": "{{count}} 字符",
|
||||||
|
"copyMarkdown": "复制 Markdown",
|
||||||
|
"download": "下载",
|
||||||
|
"emptyHint": "输入 HTML 内容以查看转换结果"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "JSON 差异比较",
|
||||||
|
"pageSubtitle": "对比两个 JSON 数据的差异",
|
||||||
|
"leftPlaceholder": "输入原始 JSON...",
|
||||||
|
"rightPlaceholder": "输入目标 JSON...",
|
||||||
|
"leftLabel": "原始 JSON",
|
||||||
|
"rightLabel": "目标 JSON",
|
||||||
|
"compareButton": "比较",
|
||||||
|
"clearButton": "清空",
|
||||||
|
"sideBySideMode": "并排",
|
||||||
|
"unifiedMode": "统一",
|
||||||
|
"previousDiff": "上一个",
|
||||||
|
"nextDiff": "下一个",
|
||||||
|
"noDiffs": "无差异",
|
||||||
|
"diffCount": "{{count}} 处差异",
|
||||||
|
"invalidJson": "无效的 JSON 格式",
|
||||||
|
"emptyHint": "输入两侧 JSON 后点击比较",
|
||||||
|
"added": "新增",
|
||||||
|
"removed": "删除",
|
||||||
|
"modified": "修改"
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"formatTitle": "JSON 格式化",
|
||||||
|
"formatSubtitle": "美化和格式化 JSON 数据",
|
||||||
|
"inputPlaceholder": "输入需要格式化的 JSON...",
|
||||||
|
"formatButton": "格式化",
|
||||||
|
"clearButton": "清空",
|
||||||
|
"sortKeys": "键名排序",
|
||||||
|
"sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序",
|
||||||
|
"indentSize": "缩进",
|
||||||
|
"indentSpaces": "{{count}} 个空格",
|
||||||
|
"outputLabel": "格式化结果",
|
||||||
|
"copySuccess": "复制成功",
|
||||||
|
"copyFail": "复制失败",
|
||||||
|
"noContent": "无内容可复制",
|
||||||
|
"invalidJson": "无效的 JSON 格式",
|
||||||
|
"emptyHint": "输入 JSON 后点击格式化",
|
||||||
|
"originalSize": "原始大小",
|
||||||
|
"formattedSize": "格式化后大小",
|
||||||
|
"diffMode": "差异比较",
|
||||||
|
"formatMode": "格式化",
|
||||||
|
"yamlMode": "YAML",
|
||||||
|
"tomlMode": "TOML",
|
||||||
|
"minifyMode": "压缩",
|
||||||
|
"yamlTitle": "JSON 转 YAML",
|
||||||
|
"yamlSubtitle": "将 JSON 数据转换为 YAML 格式",
|
||||||
|
"yamlModeInputPlaceholder": "输入需要转换的 JSON...",
|
||||||
|
"yamlModeOutputLabel": "YAML 结果",
|
||||||
|
"yamlModeEmptyHint": "输入 JSON 后点击转换",
|
||||||
|
"convertButton": "转换",
|
||||||
|
"tomlTitle": "JSON 转 TOML",
|
||||||
|
"tomlSubtitle": "将 JSON 数据转换为 TOML 格式",
|
||||||
|
"tomlModeInputPlaceholder": "输入需要转换的 JSON...",
|
||||||
|
"tomlModeOutputLabel": "TOML 结果",
|
||||||
|
"tomlModeEmptyHint": "输入 JSON 后点击转换",
|
||||||
|
"minifyTitle": "JSON 压缩",
|
||||||
|
"minifySubtitle": "将 JSON 压缩为紧凑的单行格式",
|
||||||
|
"minifyModeInputPlaceholder": "输入需要压缩的 JSON...",
|
||||||
|
"minifyModeOutputLabel": "压缩结果",
|
||||||
|
"minifyModeEmptyHint": "输入 JSON 后点击压缩",
|
||||||
|
"minifyButton": "压缩"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "JWT 解析",
|
||||||
|
"pageSubtitle": "JSON Web Token 解码与查看",
|
||||||
|
"placeholder": "在此粘贴 JWT 令牌 (Encoded JWT)...",
|
||||||
|
"headerTitle": "HEADER: 算法 & 令牌类型",
|
||||||
|
"payloadTitle": "PAYLOAD: 数据",
|
||||||
|
"signatureTitle": "签名",
|
||||||
|
"noSignature": "无签名",
|
||||||
|
"invalidFormat": "无法解析",
|
||||||
|
"errors": {
|
||||||
|
"invalidBase64String": "无效的 Base64URL 字符串",
|
||||||
|
"failedToDecode": "Base64URL 解码失败:",
|
||||||
|
"invalidFormat": "JWT 格式错误:必须包含三个由 . 分隔的部分",
|
||||||
|
"parseHeaderFailed": "解析 Header 失败:",
|
||||||
|
"parsePayloadFailed": "解析 Payload 失败:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Markdown 转 HTML",
|
||||||
|
"pageSubtitle": "实时将 Markdown 转换为 HTML 并预览",
|
||||||
|
"splitMode": "分屏",
|
||||||
|
"previewMode": "预览",
|
||||||
|
"htmlMode": "HTML",
|
||||||
|
"clear": "清空",
|
||||||
|
"print": "打印",
|
||||||
|
"download": "下载",
|
||||||
|
"inputLabel": "Markdown 输入",
|
||||||
|
"previewLabel": "实时预览",
|
||||||
|
"htmlOutputLabel": "HTML 输出",
|
||||||
|
"inputPlaceholder": "在此输入 Markdown 内容...",
|
||||||
|
"charCount": "{{count}} 字符",
|
||||||
|
"copyHtml": "复制 HTML",
|
||||||
|
"downloadSuccess": "文件下载成功",
|
||||||
|
"printSuccess": "打印窗口已打开"
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "二维码工具",
|
||||||
|
"pageSubtitle": "生成和解析二维码",
|
||||||
|
|
||||||
|
"generateMode": "生成二维码",
|
||||||
|
"parseMode": "解析二维码",
|
||||||
|
|
||||||
|
"urlToQr": "文本转二维码",
|
||||||
|
"qrToUrl": "二维码转文本",
|
||||||
|
|
||||||
|
"urlInputLabel": "输入 URL 或文本",
|
||||||
|
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
|
||||||
|
"generateButton": "生成二维码",
|
||||||
|
"generating": "生成中...",
|
||||||
|
|
||||||
|
"qrCodeWillShow": "二维码将显示在这里",
|
||||||
|
"downloadButton": "下载二维码",
|
||||||
|
"copyQrButton": "复制二维码",
|
||||||
|
|
||||||
|
"qrCodeSuccess": "二维码生成成功",
|
||||||
|
"qrCodeDownloadSuccess": "二维码下载成功",
|
||||||
|
"qrCodeCopySuccess": "二维码已复制到剪贴板",
|
||||||
|
|
||||||
|
"selectImage": "请选择二维码图片",
|
||||||
|
"parseSuccess": "二维码解析成功",
|
||||||
|
"noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
|
||||||
|
"parseError": "解析二维码失败,请重试",
|
||||||
|
"generateError": "生成二维码失败,请检查输入内容",
|
||||||
|
"copyError": "复制失败,请重试",
|
||||||
|
|
||||||
|
"imagePasted": "图片粘贴成功,正在解析...",
|
||||||
|
"imagePasteError": "粘贴图片失败,请重试",
|
||||||
|
"imageCleared": "图片已清除",
|
||||||
|
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
|
||||||
|
"supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
|
||||||
|
|
||||||
|
"parseButton": "解析二维码",
|
||||||
|
"parsing": "解析中...",
|
||||||
|
"resultLabel": "解析结果",
|
||||||
|
"copyTooltip": "复制",
|
||||||
|
|
||||||
|
"enterUrlError": "请输入 URL 或文本",
|
||||||
|
"clickToChange": "点击更换图片",
|
||||||
|
|
||||||
|
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
|
||||||
|
"autoGenerateHint": "输入内容后将自动生成二维码",
|
||||||
|
"autoParseHint": "上传图片后将自动解析二维码"
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"title": "右键恢复",
|
||||||
|
"description": "检测并恢复被网站禁用的浏览器右键菜单",
|
||||||
|
"loading": "正在加载...",
|
||||||
|
"currentDomain": "当前域名",
|
||||||
|
"statusLocked": "未解锁",
|
||||||
|
"statusUnlocked": "已解锁",
|
||||||
|
"unsupported": "不支持",
|
||||||
|
"unsupportedDesc": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
|
||||||
|
"unlockDesc": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
|
||||||
|
"unlockBtn": "解锁当前网站右键",
|
||||||
|
"alreadyUnlocked": "右键已解锁"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "存储清理",
|
||||||
|
"pageSubtitle": "清理缓存、Cookies 及本地存储",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"occupied": "已占用 {{size}}",
|
||||||
|
"cleaning": "正在清理...",
|
||||||
|
"cleanNow": "立即清理",
|
||||||
|
"autoRefresh": "清理后自动刷新页面",
|
||||||
|
"selectAll": "全选所有项",
|
||||||
|
"cleanSuccess": "清理完成",
|
||||||
|
"cleanError": "清理失败",
|
||||||
|
"noData": "无数据",
|
||||||
|
"countUnit": "个",
|
||||||
|
"errorNoTab": "无法获取当前标签页",
|
||||||
|
"errorRestricted": "存储清理功能不支持此页面",
|
||||||
|
"cleanSuccessReload": "清理成功,即将刷新页面",
|
||||||
|
"errorStandardOnly": "存储清理功能仅适用于标准网页",
|
||||||
|
"confirmTitle": "确认清理数据?",
|
||||||
|
"confirmDesc": "您将永久删除当前页面的以下选定存储项。",
|
||||||
|
"irreversible": "此操作不可撤销",
|
||||||
|
"confirmAction": "确认清理",
|
||||||
|
"cleanedSummary": "清理了 {{items}}",
|
||||||
|
"noDataToClean": "该页面没有可清理的存储数据",
|
||||||
|
"partialFailure": "部分清理失败",
|
||||||
|
"options": {
|
||||||
|
"localStorage": "Local Storage",
|
||||||
|
"sessionStorage": "Session Storage",
|
||||||
|
"indexedDB": "IndexedDB",
|
||||||
|
"cookies": "Cookies",
|
||||||
|
"cacheStorage": "Cache Storage",
|
||||||
|
"serviceWorkers": "Service Workers"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "文本统计",
|
||||||
|
"pageSubtitle": "实时分析文本的字符、单词、行数及字节大小",
|
||||||
|
"placeholder": "在此输入或粘贴文本...",
|
||||||
|
"characters": "字符数",
|
||||||
|
"words": "单词数",
|
||||||
|
"lines": "行数",
|
||||||
|
"bytes": "字节大小"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "时间戳转换",
|
||||||
|
"pageSubtitle": "Unix 毫秒数转换与格式化",
|
||||||
|
"tsToDate": "时间戳 → 日期",
|
||||||
|
"dateToTs": "日期 → 时间戳",
|
||||||
|
"placeholderTs": "输入时间戳...",
|
||||||
|
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
|
||||||
|
"unitMs": "毫秒 (ms)",
|
||||||
|
"unitS": "秒 (s)",
|
||||||
|
"convertButton": "立即转换",
|
||||||
|
"currentTs": "当前时间戳",
|
||||||
|
"useNowTooltip": "填充到下方",
|
||||||
|
"copyTsTooltip": "复制时间戳",
|
||||||
|
"usedSuccess": "已使用当前时间戳",
|
||||||
|
"resultLabel": "转换结果",
|
||||||
|
"copyResultTooltip": "复制结果",
|
||||||
|
"relativeTime": "相对时间",
|
||||||
|
"iso8601": "ISO 8601",
|
||||||
|
"utcTime": "UTC 时间",
|
||||||
|
"copyTooltip": "复制",
|
||||||
|
"resultEmpty": "请输入并点击转换",
|
||||||
|
"errors": {
|
||||||
|
"invalidNumber": "无效数字",
|
||||||
|
"invalidTimestamp": "无效时间戳",
|
||||||
|
"invalidFormat": "格式错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
+1
-12
@@ -1,15 +1,4 @@
|
|||||||
export default {
|
export default {
|
||||||
// 对于代码文件:
|
'*.{ts,tsx,js,jsx,mjs}': ['eslint --fix --max-warnings=0 --no-warn-ignored', 'prettier --write'],
|
||||||
'*.{ts,tsx,js,jsx}': [
|
|
||||||
// 1. ESLint: 依然检查具体文件,拦截未使用变量
|
|
||||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
|
||||||
|
|
||||||
// 2. TypeScript: 使用函数形式
|
|
||||||
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好”
|
|
||||||
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
|
|
||||||
() => 'tsc --noEmit --skipLibCheck',
|
|
||||||
],
|
|
||||||
|
|
||||||
// 对于其他文件:
|
|
||||||
'*.{json,css,scss,md}': ['prettier --write'],
|
'*.{json,css,scss,md}': ['prettier --write'],
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+5313
-3040
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user