Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(npx vitest:*)",
|
||||||
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(git reset:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx eslint:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(pnpm typecheck *)",
|
||||||
|
"Bash(pnpm test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-18
@@ -13,9 +13,13 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization)
|
||||||
name: Lint
|
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
|
||||||
|
setup:
|
||||||
|
name: Prepare Dependencies
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
cache-key: ${{ steps.cache-info.outputs.key }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -24,17 +28,53 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
# 建立基于 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
|
- name: Install dependencies
|
||||||
|
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
run: npm ci
|
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
|
- name: Run ESLint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript Check
|
name: TypeScript Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -43,17 +83,24 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Restore Node Modules Instantantly
|
||||||
run: npm ci
|
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
|
- name: Run TypeScript type check
|
||||||
run: npm run compile
|
run: npm run typecheck
|
||||||
|
|
||||||
|
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -62,21 +109,30 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Restore Node Modules Instantantly
|
||||||
run: npm ci
|
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
|
- name: Run tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
|
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance)
|
||||||
build:
|
build:
|
||||||
name: Build (${{ matrix.browser }})
|
name: Build (${{ matrix.browser }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, typecheck, test]
|
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
|
||||||
|
needs: [ lint, typecheck, test ]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
browser: [chrome]
|
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
|
||||||
|
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
|
||||||
|
browser: [ chrome, firefox ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -86,11 +142,21 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Restore Node Modules Instantantly
|
||||||
run: npm ci
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
- name: Build (Chrome)
|
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
|
||||||
if: matrix.browser == 'chrome'
|
- name: Generate WXT types
|
||||||
run: npm run build
|
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
|
||||||
+103
-65
@@ -9,97 +9,136 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Phase 1: 全量 CI 检查 ────────────────────────────────────────────
|
# ── 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:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
- name: Install dependencies
|
path: node_modules
|
||||||
run: npm ci
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npm run lint
|
||||||
- name: Run ESLint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript Check
|
name: TypeScript Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
- name: Install dependencies
|
path: node_modules
|
||||||
run: npm ci
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
- name: Run TypeScript type check
|
- run: npm run typecheck
|
||||||
run: npm run compile
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- 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
|
||||||
|
|
||||||
- name: Install dependencies
|
# ── Phase 3: 打包 ───────────────────────
|
||||||
run: npm ci
|
build-extension:
|
||||||
|
name: Package Extension
|
||||||
- name: Run tests
|
|
||||||
run: npm run test
|
|
||||||
|
|
||||||
# ── Phase 2: 打包 & 发布 ─────────────────────────────────────────────
|
|
||||||
release:
|
|
||||||
name: Package & Release
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, typecheck, test]
|
needs: [lint, typecheck, test]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Build and Zip Extension
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Package Chrome extension
|
|
||||||
run: npm run zip
|
|
||||||
|
|
||||||
- name: Package Firefox extension
|
|
||||||
run: npm run zip:firefox
|
|
||||||
|
|
||||||
- name: Find zip artifacts
|
|
||||||
id: find_zips
|
|
||||||
run: |
|
run: |
|
||||||
CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1)
|
npm run zip
|
||||||
FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1)
|
npm run zip:firefox
|
||||||
echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT"
|
# 🔍 调试:确认 zip 文件位置
|
||||||
echo "Found Chrome zip: $CHROME_ZIP"
|
- name: Verify output files
|
||||||
echo "Found Firefox zip: $FIREFOX_ZIP"
|
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
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
@@ -114,5 +153,4 @@ jobs:
|
|||||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
${{ steps.find_zips.outputs.chrome_zip }}
|
release-artifacts/*.zip
|
||||||
${{ steps.find_zips.outputs.firefox_zip }}
|
|
||||||
|
|||||||
+4
-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/*
|
||||||
@@ -28,3 +27,7 @@ stats-*.json
|
|||||||
|
|
||||||
.trae/*
|
.trae/*
|
||||||
.workbuddy/*
|
.workbuddy/*
|
||||||
|
.qoder/*
|
||||||
|
dev/*
|
||||||
|
|
||||||
|
docs/*
|
||||||
|
|||||||
@@ -1,315 +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 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
|
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `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 v2 - 测试框架
|
## 验证流程
|
||||||
- @testing-library/react v16 - React 组件测试
|
|
||||||
- @testing-library/user-event v14 - 用户交互模拟
|
|
||||||
- jsdom v25 - 浏览器环境模拟
|
|
||||||
|
|
||||||
### 依赖与准备
|
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 v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
|
## WXT 生成文件
|
||||||
- **前端**: React 19 + TypeScript 5
|
|
||||||
- **UI 库**: Material UI (MUI) v7 + Emotion
|
|
||||||
- **状态管理**: React Hooks + 自定义 Hooks
|
|
||||||
- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式)
|
|
||||||
- **测试**: Vitest + Testing Library
|
|
||||||
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged
|
|
||||||
|
|
||||||
### 目录结构
|
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
|
||||||
|
- 生产构建输出到 `.output/` 目录。
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── components/ # 可复用 UI 组件
|
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||||
│ ├── __tests__/ # 组件测试文件
|
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
||||||
│ ├── Button.tsx # 按钮组件
|
pages/ # 功能页面组件 (懒加载)
|
||||||
│ ├── CopyButton.tsx # 复制按钮组件
|
components/ # 可复用 UI 组件
|
||||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件
|
providers/ # React Context (Router, Theme 等)
|
||||||
│ ├── FieldList.tsx # 字段列表组件
|
hooks/ # 自定义 React Hooks
|
||||||
│ ├── GlobalSnackbar.tsx # 全局提示消息组件
|
utils/ # 工具函数与服务抽象
|
||||||
│ ├── PageHeader.tsx # 页面头部组件
|
types/ # TypeScript 类型声明
|
||||||
│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件
|
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json 及各功能独立 JSON)
|
||||||
│ ├── QrCodeUploader.tsx # 二维码上传组件
|
|
||||||
│ ├── RouterContainer.tsx # 路由容器组件
|
|
||||||
│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件
|
|
||||||
│ ├── ToolCard.tsx # 工具卡片组件
|
|
||||||
│ ├── TopBar.tsx # 顶部导航栏组件
|
|
||||||
│ ├── UrlEntryForm.tsx # URL 录入表单组件
|
|
||||||
│ ├── UrlEntryItem.tsx # URL 条目组件
|
|
||||||
│ ├── UrlEntryList.tsx # URL 列表组件
|
|
||||||
│ └── UrlToQrCodeSection.tsx # URL 转二维码组件
|
|
||||||
├── config/ # 配置文件
|
|
||||||
│ ├── __tests__/ # 配置测试文件
|
|
||||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置
|
|
||||||
│ ├── pageTheme.ts # 页面主题配置
|
|
||||||
│ ├── routes.ts # 路由配置
|
|
||||||
│ └── theme.ts # 全局主题配置
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台脚本(主进程)
|
|
||||||
│ ├── content.ts # 内容脚本(注入到页面)
|
|
||||||
│ ├── content/
|
|
||||||
│ │ └── messageHandler.ts # 消息处理器
|
|
||||||
│ ├── options/ # 选项页面
|
|
||||||
│ │ ├── App.tsx # 选项应用
|
|
||||||
│ │ ├── index.html # 选项页面 HTML
|
|
||||||
│ │ └── main.tsx # 选项页面入口
|
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
|
||||||
│ │ ├── App.tsx # 弹窗主应用
|
|
||||||
│ │ ├── main.tsx # 弹窗入口
|
|
||||||
│ │ ├── index.html # 弹窗 HTML
|
|
||||||
│ │ ├── pages/ # 弹窗页面
|
|
||||||
│ │ │ ├── components/ # 页面级组件
|
|
||||||
│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关
|
|
||||||
│ │ │ │ ├── CleaningResult.tsx # 清理结果展示
|
|
||||||
│ │ │ │ ├── DomainHeader.tsx # 域名头部
|
|
||||||
│ │ │ │ ├── ErrorDisplay.tsx # 错误显示
|
|
||||||
│ │ │ │ ├── LiveClock.tsx # 实时时钟
|
|
||||||
│ │ │ │ ├── OptionItem.tsx # 选项条目
|
|
||||||
│ │ │ │ ├── ResultView.tsx # 结果视图
|
|
||||||
│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格
|
|
||||||
│ │ │ ├── hooks/ # 自定义 Hooks
|
|
||||||
│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名
|
|
||||||
│ │ │ │ ├── useFormRecognizer.ts # 表单识别
|
|
||||||
│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态
|
|
||||||
│ │ │ │ └── useTimestampConverter.ts # 时间戳转换
|
|
||||||
│ │ │ ├── DashboardPage.tsx # 仪表盘页面
|
|
||||||
│ │ │ ├── FormFillPage.tsx # 表单填充页面
|
|
||||||
│ │ │ ├── FormMappingPage.tsx # 表单映射页面
|
|
||||||
│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面
|
|
||||||
│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面
|
|
||||||
│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面
|
|
||||||
│ │ │ ├── QrCodePage.tsx # 二维码页面
|
|
||||||
│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面
|
|
||||||
│ │ │ ├── TimestampPage.tsx # 时间戳页面
|
|
||||||
│ │ │ └── useStorageCleaner.ts # 存储清理 Hook
|
|
||||||
│ └── sidepanel/ # 侧边栏界面
|
|
||||||
│ ├── App.tsx # 侧边栏应用
|
|
||||||
│ ├── index.html # 侧边栏 HTML
|
|
||||||
│ └── main.tsx # 侧边栏入口
|
|
||||||
├── providers/ # React Providers
|
|
||||||
│ └── RouterProvider.tsx # 路由 Provider
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── __tests__/ # 工具测试文件
|
|
||||||
│ ├── formMapping/ # 表单映射工具
|
|
||||||
│ │ ├── highlighter.ts # 表单高亮器
|
|
||||||
│ │ ├── scanner.ts # 表单扫描器
|
|
||||||
│ │ ├── smartInjector.ts # 智能注入器
|
|
||||||
│ │ └── ui.ts # UI 工具
|
|
||||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
|
||||||
│ ├── chromeTabs.ts # Chrome 标签页工具
|
|
||||||
│ ├── clipboard.ts # 剪贴板工具
|
|
||||||
│ ├── dataTemplate.ts # 数据模板
|
|
||||||
│ ├── dataValidator.ts # 数据验证器
|
|
||||||
│ ├── dayjs.ts # 日期处理工具
|
|
||||||
│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker)
|
|
||||||
│ ├── messages.ts # 消息通信工具
|
|
||||||
│ ├── qrCodeParser.ts # 二维码解析器
|
|
||||||
│ ├── storageCleaner.ts # 存储清理工具
|
|
||||||
│ ├── useStorageState.ts # 存储状态 Hook
|
|
||||||
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # 存储相关类型
|
|
||||||
├── docs/ # 文档
|
|
||||||
│ └── plans/ # 计划文档
|
|
||||||
├── public/ # 静态资源
|
|
||||||
│ └── icon/ # 扩展图标
|
|
||||||
└── .github/ # GitHub 配置
|
|
||||||
└── workflows/ # CI/CD 工作流
|
|
||||||
├── ci.yml # 持续集成
|
|
||||||
└── release.yml # 发布流程
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心功能模块
|
## 关键架构决策
|
||||||
|
|
||||||
#### 1. 时间戳转换工具
|
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||||
|
渲染对应组件。支持三种渲染模式: popup / sidepanel / tab。
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/TimestampPage.tsx`
|
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其
|
||||||
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
|
Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||||
- 依赖: dayjs 库进行日期处理
|
|
||||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
|
|
||||||
|
|
||||||
#### 2. 存储清理工具
|
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||||
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
|
|
||||||
- 工具: `utils/storageCleaner.ts`
|
|
||||||
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
|
|
||||||
|
|
||||||
#### 3. URL 管理工具
|
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||||
|
|
||||||
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx`
|
## 测试环境
|
||||||
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
|
|
||||||
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
|
|
||||||
- 功能: 批量打开多个 URL,URL 列表管理
|
|
||||||
|
|
||||||
#### 4. 二维码工具
|
- 环境: 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}`
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/QrCodePage.tsx`
|
## i18n
|
||||||
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
|
|
||||||
- 工具: `utils/qrCodeParser.ts`
|
|
||||||
- 依赖: qrcode, jsqr 库
|
|
||||||
- 功能: URL 转二维码生成,二维码图片解析为 URL
|
|
||||||
|
|
||||||
#### 5. 表单工具套件
|
- 命名空间: `common` (默认), `features`
|
||||||
|
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
||||||
|
- 语言: `zh` (默认), `en`
|
||||||
|
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json` 及对应功能独立 JSON
|
||||||
|
|
||||||
**表单识别 (Form Recognizer)**
|
## 新功能开发清单
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx`
|
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts`
|
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
||||||
- 功能: 智能识别页面表单指纹
|
3. 在 `pages/` 创建页面组件 (懒加载)
|
||||||
|
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON)
|
||||||
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
**表单映射 (Form Mapping)**
|
## 代码规范
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/FormMappingPage.tsx`
|
- 禁止使用 `any` (测试文件除外)
|
||||||
- 工具: `utils/formMapping/` 目录
|
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||||
- `scanner.ts` - 表单扫描器
|
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||||
- `highlighter.ts` - 表单高亮器
|
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||||
- `smartInjector.ts` - 智能注入器
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||||
- `ui.ts` - UI 工具
|
|
||||||
- 功能: 表单指纹识别与自定义映射规则配置
|
|
||||||
|
|
||||||
**表单填充 (Form Fill)**
|
## 技术栈版本
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/FormFillPage.tsx`
|
- WXT: ^0.20.26
|
||||||
- 工具: `utils/dummyDataGenerator.ts` (基于 @faker-js/faker)
|
- React: ^19.2.6
|
||||||
- 功能: 根据表单指纹智能填充表单数据
|
- Tailwind CSS: ^3.4.19
|
||||||
|
- shadcn/ui (基于 Radix UI + class-variance-authority)
|
||||||
#### 6. 仪表盘系统
|
- TypeScript: ^5.9.3
|
||||||
|
- Vitest: ^4.1.7
|
||||||
- 位置: `entrypoints/popup/pages/DashboardPage.tsx`
|
- i18next: ^26.2.0
|
||||||
- 配置: `config/features.tsx`
|
|
||||||
- 组件: `components/DashboardCard.tsx`, `components/ToolCard.tsx`
|
|
||||||
- 功能: 统一工具入口,可自定义显示的工具卡片
|
|
||||||
|
|
||||||
#### 7. 多模式显示系统
|
|
||||||
|
|
||||||
- 支持三种显示模式:
|
|
||||||
- **popup** - 扩展弹窗(点击图标显示)
|
|
||||||
- **sidepanel** - 浏览器侧边栏
|
|
||||||
- **detached** - 独立窗口模式
|
|
||||||
- 路由配置: `config/features.tsx`
|
|
||||||
- 路由容器: `components/RouterContainer.tsx`
|
|
||||||
- Provider: `providers/RouterProvider.tsx`
|
|
||||||
|
|
||||||
#### 8. 通信系统
|
|
||||||
|
|
||||||
- 位置: `utils/messages.ts`
|
|
||||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
|
||||||
- 内容脚本消息处理: `entrypoints/content/messageHandler.ts`
|
|
||||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏
|
|
||||||
|
|
||||||
#### 9. 数据存储
|
|
||||||
|
|
||||||
- Chrome Storage API: `utils/chromeStorage.ts`
|
|
||||||
- 存储状态 Hook: `utils/useStorageState.ts`
|
|
||||||
- URL 偏好设置: `utils/useUrlPreferences.ts`
|
|
||||||
- 类型定义: `types/storage.d.ts`
|
|
||||||
|
|
||||||
### 关键配置文件
|
|
||||||
|
|
||||||
#### wxt.config.ts
|
|
||||||
|
|
||||||
- 配置 WXT 框架参数
|
|
||||||
- 启用 React 模块
|
|
||||||
- 配置浏览器扩展权限(storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel)
|
|
||||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
|
||||||
- 配置侧边栏和选项页面
|
|
||||||
|
|
||||||
#### manifest 权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage', // 存储权限
|
|
||||||
'unlimitedStorage', // 无限制存储
|
|
||||||
'clipboardWrite', // 剪贴板写入
|
|
||||||
'activeTab', // 当前标签页
|
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'cookies', // Cookies 管理
|
|
||||||
'sidePanel', // 侧边栏
|
|
||||||
],
|
|
||||||
host_permissions:['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
|
||||||
|
|
||||||
#### CI/CD 配置
|
|
||||||
|
|
||||||
- `.github/workflows/ci.yml` - 持续集成工作流
|
|
||||||
- `.github/workflows/release.yml` - 发布工作流
|
|
||||||
|
|
||||||
## 开发注意事项
|
|
||||||
|
|
||||||
### 扩展入口点
|
|
||||||
|
|
||||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
|
||||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
|
||||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
|
||||||
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
|
|
||||||
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
|
|
||||||
|
|
||||||
### 路由系统
|
|
||||||
|
|
||||||
- 使用自定义路由系统,支持多种显示模式
|
|
||||||
- 路由配置在 `config/features.tsx`
|
|
||||||
- 通过 `getEntryPointType()` 判断当前入口点类型
|
|
||||||
- 支持页面可见性配置(`defaultVisible`)
|
|
||||||
|
|
||||||
### 浏览器兼容性
|
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
|
||||||
- 使用 `@types/chrome` 和 `@types/webextension-polyfill` 提供类型支持
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint)
|
|
||||||
- Prettier 进行代码格式化
|
|
||||||
- Husky v9 用于 Git 钩子管理
|
|
||||||
- Lint-staged 确保暂存文件符合规范
|
|
||||||
- GitHub Actions CI/CD 自动化测试和构建
|
|
||||||
|
|
||||||
### 测试策略
|
|
||||||
|
|
||||||
- 组件测试: `components/__tests__/` 目录
|
|
||||||
- 工具函数测试: `utils/__tests__/` 目录
|
|
||||||
- 配置测试: `config/__tests__/` 目录
|
|
||||||
- 使用 Vitest 作为测试框架
|
|
||||||
- 使用 Testing Library 进行 React 组件测试
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# Testing Tools Browser Extension - Gemini Instructions
|
|
||||||
|
|
||||||
This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools.
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
|
|
||||||
- **Framework:** WXT (Web Extension Toolkit)
|
|
||||||
- **Frontend:** React 19 + TypeScript
|
|
||||||
- **UI Library:** Material UI (MUI) @7.x
|
|
||||||
- **Date Handling:** dayjs (with UTC and timezone plugins)
|
|
||||||
- **Messaging:** @webext-core/messaging
|
|
||||||
- **Storage:** Type-safe Chrome Storage API wrapper
|
|
||||||
- **Testing:** Vitest + Testing Library (jsdom)
|
|
||||||
|
|
||||||
### Architecture & Directory Structure
|
|
||||||
|
|
||||||
- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content).
|
|
||||||
- `popup/`: Main UI shown when clicking the extension icon.
|
|
||||||
- `options/`: Extension settings page.
|
|
||||||
- `sidepanel/`: Browser side panel integration.
|
|
||||||
- `background.ts`: Background script for lifecycle management and background tasks.
|
|
||||||
- `content.ts`: Content script injected into web pages.
|
|
||||||
- `components/`: Reusable React components.
|
|
||||||
- `config/`: Application configuration, including routes and themes.
|
|
||||||
- `providers/`: React Context providers (e.g., `RouterProvider`).
|
|
||||||
- `utils/`: Utility functions and service abstractions.
|
|
||||||
- `chromeStorage.ts`: Type-safe storage utility.
|
|
||||||
- `types/`: Global TypeScript type definitions.
|
|
||||||
- `public/`: Static assets (icons, etc.).
|
|
||||||
|
|
||||||
## Building and Running
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- `npm run dev`: Start Chrome development mode with HMR.
|
|
||||||
- `npm run dev:firefox`: Start Firefox development mode.
|
|
||||||
- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
- `npm run build`: Build production version for Chrome.
|
|
||||||
- `npm run build:firefox`: Build production version for Firefox.
|
|
||||||
- `npm run zip`: Package the extension for Chrome Web Store.
|
|
||||||
- `npm run zip:firefox`: Package the extension for Firefox Add-ons.
|
|
||||||
|
|
||||||
### Testing & Linting
|
|
||||||
|
|
||||||
- `npm run test`: Run all tests once.
|
|
||||||
- `npm run test:watch`: Run tests in watch mode.
|
|
||||||
- `npm run test:coverage`: Run tests and generate coverage report.
|
|
||||||
- `npm run lint`: Run ESLint checks.
|
|
||||||
|
|
||||||
## Development Conventions
|
|
||||||
|
|
||||||
### Coding Style
|
|
||||||
|
|
||||||
- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
|
|
||||||
- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
|
|
||||||
- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`.
|
|
||||||
- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`.
|
|
||||||
|
|
||||||
### Testing Practices
|
|
||||||
|
|
||||||
- **Framework:** Vitest with `jsdom` environment.
|
|
||||||
- **Location:** Place tests in `__tests__` directories adjacent to the files being tested.
|
|
||||||
- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention.
|
|
||||||
- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions.
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build).
|
|
||||||
- **Releases:** Automatic release to GitHub on pushing a `v*` tag.
|
|
||||||
|
|
||||||
## Key Considerations for AI Agents
|
|
||||||
|
|
||||||
- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`.
|
|
||||||
- **Browser Compatibility:** Ensure features work in both Chrome and Firefox.
|
|
||||||
- **React 19:** Be aware of React 19 specific features and deprecations.
|
|
||||||
- **WXT Modules:** The project uses `@wxt-dev/module-react`.
|
|
||||||
@@ -1,258 +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 首页
|
### 🚀 Dashboard 首页
|
||||||
|
|
||||||
- 卡片式工具展示
|
- **工具导航**: 快速访问所有可用工具.
|
||||||
- 支持自定义工具排序和可见性
|
- **个性化定制**: 支持自定义工具的排序和可见性.
|
||||||
- 实时数据预览(时间戳等)
|
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
|
||||||
|
|
||||||
### 时间戳转换工具
|
### ⏰ 时间戳转换工具
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
- **实时显示**: 毫秒级精度显示当前系统时间.
|
||||||
- 日期与时间戳之间的双向转换
|
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
|
||||||
- 一键复制转换结果
|
- **快捷操作**: 一键复制转换结果,支持多种格式.
|
||||||
- 输入验证和错误提示
|
|
||||||
|
|
||||||
### 存储清理工具
|
### 🧹 存储清理工具
|
||||||
|
|
||||||
- 自动读取当前域名
|
- **智能识别**: 自动检测并显示当前活动标签页的域名.
|
||||||
- 支持清理多种存储类型:
|
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
|
||||||
- localStorage
|
- **细粒度控制**: 可根据需要选择特定的清理项.
|
||||||
- sessionStorage
|
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
|
||||||
- IndexedDB
|
|
||||||
- Cookies
|
|
||||||
- Cache Storage
|
|
||||||
- Service Workers
|
|
||||||
- 可选择的清理类型(默认全选)
|
|
||||||
- 确认对话框防止误操作
|
|
||||||
- 清理结果统计
|
|
||||||
- 自动刷新页面选项
|
|
||||||
|
|
||||||
### URL 工具
|
### 📝 文本统计工具
|
||||||
|
|
||||||
- 保存常用 URL 列表
|
- **实时分析**: 键入即统计,无需额外操作.
|
||||||
- 快速打开保存的 URL
|
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
|
||||||
- 支持 URL 验证和安全检查
|
- **性能优化**: 采用高性能分词算法,支持大文本处理.
|
||||||
- 内置 URL 查看器(iframe 沙箱模式)
|
|
||||||
|
|
||||||
### 二维码工具
|
### 🔑 JWT 解析工具
|
||||||
|
|
||||||
- URL 转二维码(生成器)
|
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
|
||||||
- 二维码转 URL(解析器)
|
- **格式化显示**: 以着色和格式化的 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 (类型安全封装)
|
||||||
- **二维码**: qrcode (生成) + jsqr (解析)
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
- **测试**: Vitest + Testing Library
|
- **测试**: Vitest + Testing Library
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
├── components/ # 可复用 UI 组件
|
├── components/ # 可复用 React 组件
|
||||||
│ ├── Button.tsx
|
├── config/ # 应用配置
|
||||||
│ ├── CopyButton.tsx
|
│ └── features.tsx # 功能定义与路由映射
|
||||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
|
├── entrypoints/ # 扩展程序入口点
|
||||||
│ ├── GlobalSnackbar.tsx
|
│ ├── popup/ # 点击图标弹出的主界面
|
||||||
│ ├── PageHeader.tsx # 页面标题栏组件
|
│ ├── options/ # 扩展程序设置页面
|
||||||
│ ├── RouterContainer.tsx
|
│ ├── sidepanel/ # 浏览器侧边栏集成
|
||||||
│ ├── StorageCleanerConfirm.tsx
|
│ ├── background.ts # 后台 Service Worker
|
||||||
│ ├── ToolCard.tsx
|
│ └── content.ts # 网页注入脚本
|
||||||
│ └── TopBar.tsx
|
├── pages/ # 各功能模块的页面组件
|
||||||
├── config/ # 配置文件
|
├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
|
├── hooks/ # 自定义 React Hooks
|
||||||
│ └── routes.ts # 页面路由定义
|
├── utils/ # 工具函数与服务抽象
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
├── types/ # TypeScript 类型声明
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
├── lib/ # 通用工具函数 (cn, utils 等)
|
||||||
│ │ ├── App.tsx
|
├── i18n/ # 国际化资源
|
||||||
│ │ ├── main.tsx
|
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||||
│ │ └── pages/ # 页面组件
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
│ │ ├── DashboardPage.tsx
|
└── package.json # 项目元数据与依赖管理
|
||||||
│ │ ├── OpenUrlPage.tsx
|
|
||||||
│ │ ├── OpenUrlViewerPage.tsx
|
|
||||||
│ │ ├── QrCodePage.tsx
|
|
||||||
│ │ ├── StorageCleanerPage.tsx
|
|
||||||
│ │ └── TimestampPage.tsx
|
|
||||||
│ ├── options/ # 选项页面
|
|
||||||
│ ├── sidepanel/ # 侧边栏
|
|
||||||
│ ├── background.ts # 后台脚本
|
|
||||||
│ └── content.ts # 内容脚本
|
|
||||||
├── providers/ # React Context providers
|
|
||||||
│ └── RouterProvider.tsx # 路由状态管理
|
|
||||||
├── types/ # TypeScript 类型定义
|
|
||||||
│ └── storage.d.ts
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── chromeStorage.ts
|
|
||||||
│ ├── clipboard.ts
|
|
||||||
│ ├── dayjs.ts
|
|
||||||
│ ├── messages.tsx
|
|
||||||
│ └── storageCleaner.ts
|
|
||||||
├── public/ # 静态资源
|
|
||||||
├── wxt.config.ts # WXT 配置文件
|
|
||||||
├── package.json
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 路由系统
|
## 开发与部署
|
||||||
|
|
||||||
项目实现了灵活的路由系统,支持:
|
### 开发环境要求
|
||||||
|
|
||||||
- **页面导航**: 在不同工具页面之间切换
|
- Node.js >= 18.x
|
||||||
- **路由同步**: 通过 Chrome Storage 同步路由状态
|
- npm 或 pnpm
|
||||||
- **可见性控制**: 可配置显示哪些页面
|
|
||||||
- **页面排序**: 自定义工具卡片的显示顺序
|
|
||||||
|
|
||||||
### 页面类型 (PageType)
|
### 常用命令
|
||||||
|
|
||||||
| 页面 | 说明 | 默认可见 |
|
| 命令 | 说明 |
|
||||||
| ---------------- | ---------- | -------- |
|
| ----------------------- | --------------------------------- |
|
||||||
| `dashboard` | 首页 | ✓ |
|
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||||
| `timestamp` | 时间戳转换 | ✓ |
|
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||||
| `storageCleaner` | 存储清理 | ✓ |
|
| `npm run build` | 构建 Chrome 生产版本 |
|
||||||
| `openUrl` | URL 工具 | ✓ |
|
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||||
| `qrCode` | 二维码工具 | ✓ |
|
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||||
| `openUrlViewer` | URL 查看器 | ✗ |
|
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||||
|
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||||
|
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||||
|
| `npm run test` | 运行单元测试 |
|
||||||
|
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||||
|
|
||||||
## 扩展入口点
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
| 入口点 | 说明 |
|
### 自动化流程
|
||||||
| -------------- | ------------------------ |
|
|
||||||
| **popup** | 点击扩展图标弹出的界面 |
|
|
||||||
| **options** | 扩展选项页面 |
|
|
||||||
| **sidepanel** | 浏览器侧边栏 |
|
|
||||||
| **background** | 后台脚本(生命周期管理) |
|
|
||||||
| **content** | 内容脚本(注入到网页) |
|
|
||||||
|
|
||||||
## 开发环境要求
|
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||||
|
|
||||||
- Node.js >= 18
|
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||||
- npm 或 yarn
|
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||||
|
|
||||||
## 安装与运行
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 开发模式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
|
||||||
npm run dev:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 构建生产版本
|
|
||||||
|
|
||||||
```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 代码检查
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test # 运行所有测试
|
|
||||||
npm run test:watch # 运行测试并监听文件变化
|
|
||||||
npm run test:coverage # 运行测试并生成覆盖率报告
|
|
||||||
```
|
|
||||||
|
|
||||||
## 持续集成与发布
|
|
||||||
|
|
||||||
项目使用 GitHub Actions 实现自动化 CI/CD,无需手动操作。
|
|
||||||
|
|
||||||
### CI — 持续集成
|
|
||||||
|
|
||||||
在以下场景自动触发:
|
|
||||||
|
|
||||||
- push 到 `main` / `develop` / `develop-*` 分支
|
|
||||||
- 所有 PR(合并到 `main` 或 `develop`)
|
|
||||||
|
|
||||||
自动执行:ESLint 检查 → TypeScript 类型检查 → 单元测试 → Chrome & Firefox 构建验证。
|
|
||||||
|
|
||||||
### 发布版本
|
|
||||||
|
|
||||||
只需推送符合 `v*` 格式的 Git tag,即可自动完成全量 CI 检查、打包并发布到 GitHub Release:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
> 含 `-` 的 tag(如 `v1.0.0-beta.1`)会自动标记为预发布版本(prerelease)。
|
|
||||||
|
|
||||||
工作流文件位于 `.github/workflows/`:
|
|
||||||
|
|
||||||
- `ci.yml` — 持续集成
|
|
||||||
- `release.yml` — 自动发布
|
|
||||||
|
|
||||||
## 权限说明
|
## 权限说明
|
||||||
|
|
||||||
扩展请求以下权限:
|
本扩展根据功能需要申请了以下权限:
|
||||||
|
|
||||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||||
- `cookies` - Cookie 访问
|
- `cookies`: 管理和清理网站 Cookie.
|
||||||
- `sidePanel` - 侧边栏支持
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||||
|
|
||||||
## 主要依赖
|
## 浏览器支持
|
||||||
|
|
||||||
- `react`, `react-dom` - 前端框架
|
- Chrome (及其它 Chromium 内核浏览器)
|
||||||
- `@mui/material` - UI 组件库
|
|
||||||
- `dayjs` - 日期处理
|
|
||||||
- `@webext-core/messaging` - 扩展消息通信
|
|
||||||
- `vitest` - 测试框架
|
|
||||||
- `@testing-library/react` - React 组件测试
|
|
||||||
|
|
||||||
## 浏览器兼容性
|
|
||||||
|
|
||||||
- Chrome (推荐)
|
|
||||||
- Firefox
|
- Firefox
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
基于 [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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按钮属性类型
|
|
||||||
* 继承自 MUI ButtonProps,支持所有 MUI Button 的属性
|
|
||||||
*/
|
|
||||||
export type ButtonProps = MuiButtonProps;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button - 自定义按钮组件
|
|
||||||
*
|
|
||||||
* 基于 MUI Button 的二次封装,提供统一的项目风格:
|
|
||||||
* - 禁用阴影和涟漪效果
|
|
||||||
* - 圆角设计 (borderRadius: 4)
|
|
||||||
* - 固定高度和字体大小
|
|
||||||
* - hover 时轻微上浮效果
|
|
||||||
* - 支持 sx 数组合并
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <Button variant="contained" color="primary">
|
|
||||||
* 提交
|
|
||||||
* </Button>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param sx - 自定义样式,支持数组或单个样式对象
|
|
||||||
* @param props - 其他 MUI Button 属性
|
|
||||||
* @returns 按钮组件
|
|
||||||
*/
|
|
||||||
export function Button({ sx = [], ...props }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<MuiButton
|
|
||||||
disableElevation
|
|
||||||
disableRipple
|
|
||||||
{...props}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
py: 1.6,
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: '1rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'none',
|
|
||||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
},
|
|
||||||
'&:active': {
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Button;
|
|
||||||
+71
-66
@@ -1,87 +1,92 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { IconButton, Tooltip } from '@mui/material';
|
import { Check, Copy } from 'lucide-react';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
/**
|
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
|
||||||
* 复制按钮组件属性
|
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
* @param text 要复制的文本
|
|
||||||
* @param tooltip 提示信息
|
|
||||||
* @param size 按钮大小
|
|
||||||
* @param color 按钮颜色
|
|
||||||
* @param style 自定义样式
|
|
||||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
|
||||||
*/
|
|
||||||
interface CopyButtonProps {
|
|
||||||
text: string;
|
text: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
|
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
|
||||||
style?: React.CSSProperties;
|
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制按钮组件
|
|
||||||
* @param text 要复制的文本
|
|
||||||
* @param tooltip 提示信息
|
|
||||||
* @param size 按钮大小
|
|
||||||
* @param color 按钮颜色
|
|
||||||
* @param style 自定义样式
|
|
||||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
|
||||||
* @returns 复制按钮组件
|
|
||||||
*/
|
|
||||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
text,
|
text,
|
||||||
tooltip = '复制',
|
tooltip = '复制',
|
||||||
size = 'small',
|
size = 'small',
|
||||||
color = 'primary',
|
variant = 'ghost',
|
||||||
style,
|
className,
|
||||||
showMessage,
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
useEffect(() => {
|
||||||
if (text) {
|
return () => {
|
||||||
const success = await copyToClipboard(text, showMessage);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
if (success) {
|
};
|
||||||
setCopied(true);
|
}, []);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
}
|
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 {
|
} else {
|
||||||
showMessage?.('无内容可复制', { severity: 'error' });
|
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 (
|
return (
|
||||||
<Tooltip title={tooltip}>
|
<button
|
||||||
<IconButton
|
type="button"
|
||||||
size={size}
|
onClick={handleCopy}
|
||||||
onClick={handleCopy}
|
title={tooltip}
|
||||||
style={style}
|
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
|
||||||
sx={{
|
className={cn(
|
||||||
color: copied ? 'success.main' : color,
|
'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',
|
||||||
bgcolor: '#fff',
|
sizeClasses[size],
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
copied
|
||||||
'&:hover': {
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
|
||||||
bgcolor: copied
|
: variantClasses[variant],
|
||||||
? 'success.main'
|
className, // 允许外部直接传入 text-red-500 等覆盖样式
|
||||||
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
)}
|
||||||
? color
|
{...props}
|
||||||
: `${color}.main`,
|
>
|
||||||
color: '#fff',
|
{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]" />
|
||||||
{copied ? (
|
)}
|
||||||
<CheckIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
</button>
|
||||||
) : (
|
|
||||||
<ContentCopyIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ToolCard from './ToolCard';
|
|
||||||
|
|
||||||
export interface DashboardCardConfig {
|
|
||||||
/** 卡片标题 */
|
|
||||||
title: string;
|
|
||||||
/** 卡片描述文字 */
|
|
||||||
description: string;
|
|
||||||
/** 主题颜色代码 */
|
|
||||||
colorCode: string;
|
|
||||||
/** 图标组件 */
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardCardProps {
|
|
||||||
/** 卡片配置数据 */
|
|
||||||
config: DashboardCardConfig;
|
|
||||||
/** 点击卡片时的回调函数 */
|
|
||||||
onClick: () => void;
|
|
||||||
/** 卡片右侧的实时预览内容(如时间戳显示) */
|
|
||||||
snapshot?: React.ReactNode;
|
|
||||||
/** 卡片背景色,默认使用主题色 */
|
|
||||||
cardBackgroundColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashboardCard - 仪表盘卡片组件
|
|
||||||
*
|
|
||||||
* 基于 ToolCard 的封装,专门用于仪表盘页面
|
|
||||||
* 使用 React.memo 避免不必要的重渲染
|
|
||||||
*/
|
|
||||||
const DashboardCard = React.memo(
|
|
||||||
({ config, onClick, snapshot, cardBackgroundColor }: DashboardCardProps) => (
|
|
||||||
<ToolCard
|
|
||||||
title={config.title}
|
|
||||||
description={config.description}
|
|
||||||
colorCode={config.colorCode}
|
|
||||||
icon={config.icon}
|
|
||||||
onClick={onClick}
|
|
||||||
cardBackgroundColor={cardBackgroundColor}
|
|
||||||
snapshot={snapshot}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DashboardCard;
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { Box, Typography, Button, Paper, Container } from '@mui/material';
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
import { Button } from '@/components/ui/button';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -16,83 +15,56 @@ interface State {
|
|||||||
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||||
*/
|
*/
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
public state: State = {
|
state: State = {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static getDerivedStateFromError(error: Error): State {
|
static getDerivedStateFromError(error: Error): State {
|
||||||
return { hasError: true, error };
|
return { hasError: true, error };
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
console.error('Uncaught error:', error, 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 = () => {
|
private handleReset = () => {
|
||||||
this.setState({ hasError: false, error: null });
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
|
|
||||||
public render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<Container sx={{ mt: 8 }}>
|
<div className="mt-16 mx-auto max-w-md">
|
||||||
<Paper
|
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
|
||||||
elevation={0}
|
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
|
||||||
sx={{
|
<h2 className="text-xl font-extrabold text-red-600 mb-2">糟糕,出了点问题</h2>
|
||||||
p: 4,
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'error.light',
|
|
||||||
bgcolor: 'error.shortest',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 64, mb: 2 }} />
|
|
||||||
<Typography variant="h5" fontWeight={800} gutterBottom color="error.main">
|
|
||||||
糟糕,出了点问题
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
||||||
</Typography>
|
</p>
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<Box
|
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
|
||||||
sx={{
|
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700">
|
||||||
mb: 3,
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 2,
|
|
||||||
textAlign: 'left',
|
|
||||||
maxHeight: '200px',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
color: 'error.dark',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{this.state.error.toString()}
|
{this.state.error.toString()}
|
||||||
</Typography>
|
</pre>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="default"
|
||||||
color="error"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={this.handleReset}
|
onClick={this.handleReset}
|
||||||
sx={{ borderRadius: 2, fontWeight: 700 }}
|
className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white"
|
||||||
>
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
刷新应用
|
刷新应用
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</div>
|
||||||
</Container>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
Collapse,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
SelectChangeEvent,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
|
||||||
import { FieldType } from '@/utils/dummyDataGenerator';
|
|
||||||
|
|
||||||
// 字段数据接口
|
|
||||||
interface FieldData {
|
|
||||||
id: string;
|
|
||||||
fieldType: string;
|
|
||||||
label: string | null;
|
|
||||||
placeholder: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
generatedValue: string;
|
|
||||||
useInvalidData?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字段类型显示名称映射
|
|
||||||
const FIELD_TYPE_NAMES: Record<string, string> = {
|
|
||||||
[FieldType.TEXT]: '文本',
|
|
||||||
[FieldType.EMAIL]: '邮箱',
|
|
||||||
[FieldType.PHONE]: '手机号',
|
|
||||||
[FieldType.NUMBER]: '数字',
|
|
||||||
[FieldType.DATE]: '日期',
|
|
||||||
[FieldType.TEXTarea]: '文本域',
|
|
||||||
[FieldType.RADIO]: '单选框',
|
|
||||||
[FieldType.CHECKBOX]: '复选框',
|
|
||||||
[FieldType.SELECT]: '下拉框',
|
|
||||||
[FieldType.PASSWORD]: '密码',
|
|
||||||
[FieldType.NAME]: '姓名',
|
|
||||||
[FieldType.ID_CARD]: '身份证号',
|
|
||||||
[FieldType.UNKNOWN]: '未知',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FieldListProps {
|
|
||||||
fields: FieldData[];
|
|
||||||
showFields: boolean;
|
|
||||||
onToggleShowFields: () => void;
|
|
||||||
onFieldTypeChange: (fieldId: string, newType: string) => void;
|
|
||||||
onLocateField: (fieldId: string) => void;
|
|
||||||
onHoverField: (fieldId: string | null) => void;
|
|
||||||
onToggleFieldSelection: (fieldId: string) => void;
|
|
||||||
onToggleAllFields: () => void;
|
|
||||||
hoveredFieldId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FieldList: React.FC<FieldListProps> = ({
|
|
||||||
fields,
|
|
||||||
showFields,
|
|
||||||
onToggleShowFields,
|
|
||||||
onFieldTypeChange,
|
|
||||||
onHoverField,
|
|
||||||
onToggleFieldSelection,
|
|
||||||
onToggleAllFields,
|
|
||||||
hoveredFieldId,
|
|
||||||
}) => {
|
|
||||||
if (fields.length === 0) return null;
|
|
||||||
|
|
||||||
const handleTypeChange = (fieldId: string, event: SelectChangeEvent<string>) => {
|
|
||||||
onFieldTypeChange(fieldId, event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allSelected = fields.every((f) => f.isSelected);
|
|
||||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
borderBottom: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={onToggleShowFields}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
||||||
已识别字段 ({fields.length})
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
bgcolor: selectedCount > 0 ? 'primary.main' : 'grey.300',
|
|
||||||
color: selectedCount > 0 ? 'white' : 'text.secondary',
|
|
||||||
px: 1,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedCount} 已选择
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleAllFields();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allSelected ? '取消全选' : '全选'}
|
|
||||||
</Button>
|
|
||||||
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Collapse in={showFields}>
|
|
||||||
<List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<ListItem
|
|
||||||
key={field.id}
|
|
||||||
sx={{
|
|
||||||
py: 1,
|
|
||||||
px: 2,
|
|
||||||
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'transparent',
|
|
||||||
transition: 'background-color 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => onHoverField(field.id)}
|
|
||||||
onMouseLeave={() => onHoverField(null)}
|
|
||||||
>
|
|
||||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={field.isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleFieldSelection(field.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemIcon>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
flex: 1,
|
|
||||||
opacity: field.isSelected ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<FormControl size="small" sx={{ flex: 1, minWidth: 120 }}>
|
|
||||||
<InputLabel>类型</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={field.fieldType}
|
|
||||||
label="类型"
|
|
||||||
onChange={(e) => handleTypeChange(field.id, e)}
|
|
||||||
>
|
|
||||||
{Object.values(FieldType).map((type) => (
|
|
||||||
<MenuItem key={type} value={type}>
|
|
||||||
{FIELD_TYPE_NAMES[type] || type}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{field.placeholder && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
||||||
占位符: {field.placeholder}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FieldList;
|
|
||||||
+143
-107
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件
|
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
||||||
*
|
*
|
||||||
* 提供可复用的 Toast 消息提示功能,支持两种使用方式:
|
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
||||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||||
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
|
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||||
|
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||||
*
|
*
|
||||||
* @module GlobalSnackbar
|
* @module GlobalSnackbar
|
||||||
* @version 1.0.0
|
* @version 1.1.0
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -18,14 +19,33 @@
|
|||||||
* severity="success"
|
* severity="success"
|
||||||
* />
|
* />
|
||||||
*
|
*
|
||||||
* // 方式二:Hook 方式
|
* // 方式二:Hook 方式 (局部状态)
|
||||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||||
* showMessage('Hello!', { severity: 'info' });
|
* showMessage('Hello!', { severity: 'info' });
|
||||||
|
*
|
||||||
|
* // 方式三:Context 方式 (全局状态)
|
||||||
|
* // 在根组件包裹 Provider
|
||||||
|
* <SnackbarProvider>
|
||||||
|
* <App />
|
||||||
|
* </SnackbarProvider>
|
||||||
|
*
|
||||||
|
* // 在子组件中使用
|
||||||
|
* const { showMessage } = useSnackbar();
|
||||||
|
* showMessage('Global Message');
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { JSX, useState } from 'react';
|
import {
|
||||||
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
|
JSX,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
type ReactNode,
|
||||||
|
type SyntheticEvent,
|
||||||
|
} from 'react';
|
||||||
|
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snackbar 消息严重程度类型
|
* Snackbar 消息严重程度类型
|
||||||
@@ -37,12 +57,6 @@ import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/m
|
|||||||
*/
|
*/
|
||||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||||
|
|
||||||
/**
|
|
||||||
* 重新导出 SnackbarProvider 组件
|
|
||||||
* @description 提供 Context 方式的全局 Snackbar 功能
|
|
||||||
*/
|
|
||||||
export { SnackbarProvider } from './SnackbarProvider';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GlobalSnackbar 组件的属性接口
|
* GlobalSnackbar 组件的属性接口
|
||||||
* @interface GlobalSnackbarProps
|
* @interface GlobalSnackbarProps
|
||||||
@@ -68,9 +82,9 @@ export interface GlobalSnackbarProps {
|
|||||||
/** 是否隐藏 Alert 图标,默认 false */
|
/** 是否隐藏 Alert 图标,默认 false */
|
||||||
hideIcon?: boolean;
|
hideIcon?: boolean;
|
||||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
/** 自定义样式,透传给外层 Snackbar 组件 */
|
||||||
sx?: SxProps<Theme>;
|
sx?: React.CSSProperties;
|
||||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
||||||
alertSx?: SxProps<Theme>;
|
alertSx?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,31 +132,23 @@ const defaultProps: Required<
|
|||||||
hideIcon: false,
|
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 组件
|
* GlobalSnackbar 组件
|
||||||
*
|
*
|
||||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
||||||
* 使用 MUI Snackbar 和 Alert 组件实现消息提示功能。
|
|
||||||
*
|
*
|
||||||
* @param {GlobalSnackbarProps} props - 组件属性
|
* @param {GlobalSnackbarProps} props - 组件属性
|
||||||
* @returns {JSX.Element}
|
* @returns {JSX.Element}
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - 使用 Portal 组件将 Snackbar 渲染到 body 末尾,避免 z-index 问题
|
|
||||||
* - 默认位置在屏幕底部居中
|
|
||||||
* - 自动设置高 z-index 确保显示在其他内容之上
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 受控模式
|
|
||||||
* const [open, setOpen] = useState(false);
|
|
||||||
* <GlobalSnackbar
|
|
||||||
* message="保存成功"
|
|
||||||
* open={open}
|
|
||||||
* onClose={() => setOpen(false)}
|
|
||||||
* severity="success"
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
*/
|
||||||
export function GlobalSnackbar({
|
export function GlobalSnackbar({
|
||||||
message,
|
message,
|
||||||
@@ -152,68 +158,40 @@ export function GlobalSnackbar({
|
|||||||
autoHideDuration = defaultProps.autoHideDuration,
|
autoHideDuration = defaultProps.autoHideDuration,
|
||||||
showAlert = defaultProps.showAlert,
|
showAlert = defaultProps.showAlert,
|
||||||
hideIcon = defaultProps.hideIcon,
|
hideIcon = defaultProps.hideIcon,
|
||||||
}: GlobalSnackbarProps): JSX.Element {
|
}: GlobalSnackbarProps): JSX.Element | null {
|
||||||
/**
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
|
|
||||||
*
|
useEffect(() => {
|
||||||
* @description
|
if (open && autoHideDuration > 0) {
|
||||||
* Portal 的优势:
|
timerRef.current = setTimeout(() => {
|
||||||
* - 避免父容器 overflow、z-index 等样式影响
|
onClose();
|
||||||
* - 确保 Snackbar 始终显示在最顶层
|
}, 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 (
|
return (
|
||||||
<Portal>
|
<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">
|
||||||
<Snackbar
|
{showAlert ? (
|
||||||
open={open}
|
<div
|
||||||
autoHideDuration={autoHideDuration}
|
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
|
||||||
onClose={onClose}
|
style={{ minWidth: '140px' }}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
>
|
||||||
disableWindowBlurListener
|
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
||||||
sx={{
|
<span className="text-xs font-bold">{message}</span>
|
||||||
zIndex: 999999,
|
</div>
|
||||||
// 确保距离底部的间距,响应式设计适配不同屏幕
|
) : (
|
||||||
bottom: { xs: '24px', sm: '24px' },
|
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
|
||||||
// 固定宽度时使用 transform 实现真正的居中
|
)}
|
||||||
left: '50%',
|
</div>
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
minWidth: '140px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showAlert ? (
|
|
||||||
<Alert
|
|
||||||
severity={severity}
|
|
||||||
variant="filled"
|
|
||||||
icon={hideIcon ? false : undefined}
|
|
||||||
sx={{
|
|
||||||
// 胶囊形状,现代化的设计风格
|
|
||||||
borderRadius: '50px',
|
|
||||||
px: 2.5,
|
|
||||||
py: 0.2,
|
|
||||||
minWidth: '140px',
|
|
||||||
// 居中内容
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
// 粗体小字
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
// 移除默认渐变背景
|
|
||||||
backgroundImage: 'none',
|
|
||||||
// 添加阴影效果,颜色根据 severity 自动匹配主题色
|
|
||||||
boxShadow: (theme: Theme) =>
|
|
||||||
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
|
|
||||||
// 图标样式:白色、稍大
|
|
||||||
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
|
|
||||||
// 消息文字样式:白色、适当内边距
|
|
||||||
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</Alert>
|
|
||||||
) : undefined}
|
|
||||||
</Snackbar>
|
|
||||||
</Portal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,17 +265,7 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
|
||||||
* 处理 Snackbar 关闭事件
|
|
||||||
*
|
|
||||||
* @param {React.SyntheticEvent | Event} [_event] - 关闭事件
|
|
||||||
* @param {string} [reason] - 关闭原因:timeout | clickaway | escapeKeyDown
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 忽略 clickaway 原因(用户点击其他区域),防止误关闭
|
|
||||||
* - 其他情况调用 closeMessage 关闭
|
|
||||||
*/
|
|
||||||
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
|
|
||||||
if (reason === 'clickaway') return;
|
if (reason === 'clickaway') return;
|
||||||
closeMessage();
|
closeMessage();
|
||||||
};
|
};
|
||||||
@@ -325,8 +293,76 @@ export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarS
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Context & Provider ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GlobalSnackbar 组件的默认导出
|
* Snackbar Context 的值类型定义
|
||||||
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
|
|
||||||
*/
|
*/
|
||||||
|
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;
|
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;
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PageHeader 组件属性接口
|
|
||||||
*/
|
|
||||||
export interface PageHeaderProps {
|
|
||||||
/** 要显示的图标组件 */
|
|
||||||
icon: ReactNode;
|
|
||||||
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
|
|
||||||
iconColor?: string;
|
|
||||||
/** 主标题文本 */
|
|
||||||
title: string;
|
|
||||||
/** 副标题文本(可选) */
|
|
||||||
subtitle?: string;
|
|
||||||
/** 在标题右侧显示的徽章/标签组件(可选) */
|
|
||||||
badge?: ReactNode;
|
|
||||||
/** 图标容器的自定义样式 */
|
|
||||||
iconSx?: SxProps<Theme>;
|
|
||||||
/** 标题文本的自定义样式 */
|
|
||||||
titleSx?: SxProps<Theme>;
|
|
||||||
/** 副标题文本的自定义样式 */
|
|
||||||
subtitleSx?: SxProps<Theme>;
|
|
||||||
/** 整个组件的自定义样式 */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PageHeader - 通用页面标题栏组件
|
|
||||||
*
|
|
||||||
* 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader
|
|
||||||
* icon={<AccessTimeIcon />}
|
|
||||||
* iconColor="#1976d2"
|
|
||||||
* title="时间戳转换"
|
|
||||||
* subtitle="Unix 毫秒数转换与格式化"
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader
|
|
||||||
* icon={<StorageIcon />}
|
|
||||||
* iconColor={storageCleanerPageStyles.warningColor}
|
|
||||||
* title="存储清理"
|
|
||||||
* subtitle={domain}
|
|
||||||
* badge={<Badge>已占用 {size}</Badge>}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export default function PageHeader({
|
|
||||||
icon,
|
|
||||||
iconColor = '#1976d2',
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
badge,
|
|
||||||
iconSx,
|
|
||||||
titleSx,
|
|
||||||
subtitleSx,
|
|
||||||
sx,
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
|
|
||||||
{/* 图标容器 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
bgcolor: alpha(iconColor, 0.1),
|
|
||||||
color: iconColor,
|
|
||||||
display: 'flex',
|
|
||||||
...iconSx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
{/* 标题行(含徽章) */}
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
fontWeight={900}
|
|
||||||
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2, ...titleSx }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{badge}
|
|
||||||
</Stack>
|
|
||||||
{/* 副标题 */}
|
|
||||||
{subtitle && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontWeight: 600, ...subtitleSx }}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Alert,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
CircularProgress,
|
|
||||||
InputAdornment,
|
|
||||||
IconButton,
|
|
||||||
} from '@mui/material';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
|
||||||
|
|
||||||
interface QrCodeToUrlSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QrCodeToUrlSection = ({
|
|
||||||
expanded,
|
|
||||||
onExpandedChange,
|
|
||||||
showMessage,
|
|
||||||
}: QrCodeToUrlSectionProps) => {
|
|
||||||
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
|
|
||||||
const [parsedUrl, setParsedUrl] = useState('');
|
|
||||||
const [parseError, setParseError] = useState('');
|
|
||||||
const [parsing, setParsing] = useState(false);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback((file: File) => {
|
|
||||||
setQrCodeFile(file);
|
|
||||||
setParseError('');
|
|
||||||
setParsedUrl('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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();
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
const droppedFile = e.dataTransfer.files?.[0];
|
|
||||||
if (droppedFile) {
|
|
||||||
handleFileChange(droppedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseQrCode = async () => {
|
|
||||||
if (!qrCodeFile) {
|
|
||||||
showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setParsing(true);
|
|
||||||
setParseError('');
|
|
||||||
setParsedUrl('');
|
|
||||||
|
|
||||||
const result = await parseQrCodeFromFile(qrCodeFile);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setParsedUrl(result.data);
|
|
||||||
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || '未检测到二维码', {
|
|
||||||
severity: 'error',
|
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析二维码失败:', error);
|
|
||||||
showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
} finally {
|
|
||||||
setParsing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听粘贴事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
|
||||||
if (!expanded) return;
|
|
||||||
|
|
||||||
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('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('处理粘贴图片失败:', error);
|
|
||||||
showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('paste', handlePaste);
|
|
||||||
};
|
|
||||||
}, [expanded, showMessage, handleFileChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expanded}
|
|
||||||
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 4,
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
||||||
'&:before': { display: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<LinkIcon color="success" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
二维码转 URL
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: 200,
|
|
||||||
border: '2px dashed',
|
|
||||||
borderColor: dragging
|
|
||||||
? qrCodePageStyles.successColor
|
|
||||||
: qrCodeFile
|
|
||||||
? qrCodePageStyles.successColor
|
|
||||||
: 'grey.200',
|
|
||||||
borderRadius: 3,
|
|
||||||
p: 4,
|
|
||||||
bgcolor: dragging
|
|
||||||
? 'rgba(76, 175, 80, 0.1)'
|
|
||||||
: qrCodeFile
|
|
||||||
? 'rgba(76, 175, 80, 0.05)'
|
|
||||||
: 'grey.50',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: qrCodePageStyles.successColor,
|
|
||||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{
|
|
||||||
display: 'none',
|
|
||||||
}}
|
|
||||||
id="qr-code-upload"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="qr-code-upload"
|
|
||||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
|
||||||
>
|
|
||||||
{qrCodeFile ? (
|
|
||||||
<Box sx={{ textAlign: 'center', width: '100%', position: 'relative' }}>
|
|
||||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(qrCodeFile)}
|
|
||||||
alt="QR Code Preview"
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: 160,
|
|
||||||
borderRadius: 8,
|
|
||||||
objectFit: 'contain',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setQrCodeFile(null);
|
|
||||||
setParsedUrl('');
|
|
||||||
setParseError('');
|
|
||||||
showMessage('图片已清除', {
|
|
||||||
severity: 'success',
|
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
||||||
{qrCodeFile.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
点击更换图片
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
点击、拖拽或粘贴上传二维码图片
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
支持 PNG、JPG、WEBP 格式
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
|
|
||||||
onClick={parseQrCode}
|
|
||||||
disabled={parsing}
|
|
||||||
sx={{
|
|
||||||
py: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: qrCodePageStyles.successColor,
|
|
||||||
fontWeight: 700,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: qrCodePageStyles.successDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parsing ? '解析中...' : '解析二维码'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
mt: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="解析结果"
|
|
||||||
value={parsedUrl}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<CopyButton
|
|
||||||
text={parsedUrl}
|
|
||||||
tooltip="复制"
|
|
||||||
size="small"
|
|
||||||
color={qrCodePageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{parseError && (
|
|
||||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
|
||||||
{parseError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QrCodeToUrlSection;
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
IconButton,
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
useMediaQuery,
|
|
||||||
useTheme,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import ErrorIcon from '@mui/icons-material/Error';
|
|
||||||
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
|
||||||
import CopyButton from './CopyButton';
|
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
|
||||||
|
|
||||||
interface QrCodeUploaderProps {
|
|
||||||
onQrCodeDetected?: (data: string) => void;
|
|
||||||
supportedFormats?: string[];
|
|
||||||
maxFileSize?: number; // in bytes
|
|
||||||
timeout?: number; // in milliseconds
|
|
||||||
showPreview?: boolean;
|
|
||||||
showProgress?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
|
|
||||||
onQrCodeDetected,
|
|
||||||
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
|
|
||||||
maxFileSize = 5 * 1024 * 1024, // 5MB
|
|
||||||
timeout = 10000, // 10 seconds
|
|
||||||
showPreview = true,
|
|
||||||
showProgress = true,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 });
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [result, setResult] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const uploadAreaRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 清理预览 URL
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (preview) {
|
|
||||||
URL.revokeObjectURL(preview);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [preview]);
|
|
||||||
|
|
||||||
// 处理文件
|
|
||||||
const processFile = useCallback(
|
|
||||||
async (file: File) => {
|
|
||||||
setUploading(true);
|
|
||||||
setProgress(0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
setProgress((prev) => {
|
|
||||||
if (prev >= 90) {
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return prev + 10;
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
const result = await parseQrCodeFromFile(file, timeout);
|
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
setProgress(100);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setResult(result.data);
|
|
||||||
showMessage('二维码解析成功', { severity: 'success' });
|
|
||||||
if (onQrCodeDetected) {
|
|
||||||
onQrCodeDetected(result.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(result.error || '未检测到二维码');
|
|
||||||
showMessage(result.error || '未检测到二维码', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : '解析失败');
|
|
||||||
showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), {
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
setTimeout(() => setProgress(0), 500);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[timeout, showMessage, onQrCodeDetected],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理文件
|
|
||||||
const handleFile = useCallback(
|
|
||||||
(selectedFile: File) => {
|
|
||||||
// 检查文件格式
|
|
||||||
if (!supportedFormats.includes(selectedFile.type)) {
|
|
||||||
setError(
|
|
||||||
`不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`,
|
|
||||||
);
|
|
||||||
showMessage('不支持的文件格式', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查文件大小
|
|
||||||
if (selectedFile.size > maxFileSize) {
|
|
||||||
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
|
|
||||||
setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`);
|
|
||||||
showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
setFile(selectedFile);
|
|
||||||
|
|
||||||
// 创建预览
|
|
||||||
if (showPreview) {
|
|
||||||
const previewUrl = URL.createObjectURL(selectedFile);
|
|
||||||
setPreview(previewUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始处理
|
|
||||||
processFile(selectedFile).catch(console.error);
|
|
||||||
},
|
|
||||||
[supportedFormats, maxFileSize, showPreview, showMessage, processFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理文件选择
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const selectedFile = e.target.files?.[0];
|
|
||||||
if (selectedFile) {
|
|
||||||
handleFile(selectedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理拖拽事件
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
const droppedFile = e.dataTransfer.files?.[0];
|
|
||||||
if (droppedFile) {
|
|
||||||
handleFile(droppedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听粘贴事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (items[i].type.startsWith('image/')) {
|
|
||||||
e.preventDefault();
|
|
||||||
const pastedFile = items[i].getAsFile();
|
|
||||||
if (pastedFile) {
|
|
||||||
handleFile(pastedFile);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
return () => document.removeEventListener('paste', handlePaste);
|
|
||||||
}, [handleFile]);
|
|
||||||
|
|
||||||
// 清除文件
|
|
||||||
const handleClear = () => {
|
|
||||||
setFile(null);
|
|
||||||
setPreview(null);
|
|
||||||
setResult(null);
|
|
||||||
setError(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={className}>
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<Paper
|
|
||||||
ref={uploadAreaRef}
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: isMobile ? 3 : 4,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: `2px dashed ${dragging ? 'primary.main' : 'grey.300'}`,
|
|
||||||
bgcolor: dragging ? 'primary.lighter' : 'grey.50',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
textAlign: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={supportedFormats.join(',')}
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!file && !uploading ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<ImageIcon sx={{ fontSize: isMobile ? 36 : 48, color: 'grey.400', mb: 2 }} />
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
点击、拖拽或粘贴上传二维码图片
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
|
||||||
最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : file && showPreview && preview ? (
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<img
|
|
||||||
src={preview}
|
|
||||||
alt="QR Code Preview"
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: 200,
|
|
||||||
borderRadius: 8,
|
|
||||||
objectFit: 'contain',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClear();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
||||||
{file.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : uploading && showProgress ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
处理中...
|
|
||||||
</Typography>
|
|
||||||
{progress > 0 && (
|
|
||||||
<Box sx={{ width: '80%', mt: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 8,
|
|
||||||
bgcolor: 'grey.200',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: '100%',
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
width: `${progress}%`,
|
|
||||||
transition: 'width 0.3s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ mt: 1, display: 'block' }}
|
|
||||||
>
|
|
||||||
{progress}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* 结果展示 */}
|
|
||||||
{(result || error) && (
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
{result && (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'success.light',
|
|
||||||
bgcolor: 'success.lighter',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
|
||||||
<CheckCircleIcon sx={{ color: 'success.main', mt: 0.5 }} />
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
|
||||||
二维码内容
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
pr: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result}
|
|
||||||
</Typography>
|
|
||||||
<CopyButton
|
|
||||||
text={result}
|
|
||||||
tooltip="复制"
|
|
||||||
size="small"
|
|
||||||
color="success"
|
|
||||||
showMessage={showMessage}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ borderRadius: 4 }} icon={<ErrorIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QrCodeUploader;
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
import { useMemo } from 'react';
|
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() {
|
export default function RouterContainer() {
|
||||||
const { currentPage, isLoaded } = useRouter();
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
|
||||||
|
// 2. 稳定的动态动画类名映射
|
||||||
const animationClass = useMemo(() => {
|
const animationClass = useMemo(() => {
|
||||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
@@ -14,27 +18,48 @@ export default function RouterContainer() {
|
|||||||
return getEntryPointType();
|
return getEntryPointType();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 骨架屏加载状态守卫
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return <div className="app">Loading...</div>;
|
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 严格的路由查找与类型安全的组件分发
|
||||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||||
const Component = currentFeature ? currentFeature.components[entryPointType] : null;
|
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<div
|
||||||
key={currentPage} // Trigger animation on navigation
|
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
|
||||||
className={animationClass}
|
className={cn(
|
||||||
sx={{
|
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||||
flex: 1,
|
'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
|
||||||
overflowY: 'auto',
|
animationClass,
|
||||||
overflowX: 'hidden',
|
)}
|
||||||
scrollbarGutter: 'stable',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{Component && <Component />}
|
<Suspense
|
||||||
</Box>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* SnackbarProvider - 全局 Snackbar 消息提示 Provider
|
|
||||||
*
|
|
||||||
* 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。
|
|
||||||
* 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑
|
|
||||||
* - 使用 MUI Snackbar 组件实现消息提示
|
|
||||||
* - 支持自定义自动隐藏时长
|
|
||||||
* - 消息会显示在页面底部居中位置
|
|
||||||
* - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createContext, useContext, type ReactNode } from 'react';
|
|
||||||
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
|
||||||
import type { SnackbarOptions } from './GlobalSnackbar';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar Context 的值类型定义
|
|
||||||
* @interface SnackbarContextValue
|
|
||||||
* @property showMessage - 显示消息的方法
|
|
||||||
* @property closeMessage - 关闭消息的方法
|
|
||||||
*/
|
|
||||||
interface SnackbarContextValue {
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Context,用于在组件树中传递 Snackbar 操作方法
|
|
||||||
* @description
|
|
||||||
* - 初始值为 null,表示未包裹在 Provider 中
|
|
||||||
* - 通过 SnackbarProvider 包裹后提供实际值
|
|
||||||
*/
|
|
||||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件的 props 类型
|
|
||||||
* @interface SnackbarProviderProps
|
|
||||||
* @property children - 子组件
|
|
||||||
* @property initialOptions - 初始配置选项
|
|
||||||
*/
|
|
||||||
interface SnackbarProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
initialOptions?: SnackbarOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbar Hook 的选项配置(与 GlobalSnackbar 的 SnackbarOptions 兼容)
|
|
||||||
* @interface UseSnackbarOptions
|
|
||||||
* @property severity - 消息严重程度
|
|
||||||
* @property autoHideDuration - 默认自动隐藏时长
|
|
||||||
* @property hideIcon - 是否隐藏图标
|
|
||||||
* @property showAlert - 是否使用 Alert 组件
|
|
||||||
*/
|
|
||||||
export type UseSnackbarOptions = SnackbarOptions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
|
||||||
* 提供 showMessage 方法用于显示各种类型的提示消息。
|
|
||||||
*
|
|
||||||
* @param {SnackbarProviderProps} props - 组件属性
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - 使用 useGlobalSnackbar() hook 复用 GlobalSnackbar 的状态管理逻辑
|
|
||||||
* - 通过 Context.Provider 将操作方法传递给子组件
|
|
||||||
* - 渲染 GlobalSnackbar 组件显示实际的 Snackbar UI
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <SnackbarProvider initialOptions={{ autoHideDuration: 3000 }}>
|
|
||||||
* <App />
|
|
||||||
* </SnackbarProvider>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 在子组件中使用
|
|
||||||
* const { showMessage } = useSnackbar();
|
|
||||||
* showMessage('操作成功', { severity: 'success' });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps) {
|
|
||||||
/**
|
|
||||||
* 调用 GlobalSnackbar.useSnackbar() 获取状态管理逻辑
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - snackbarProps: 传递给 GlobalSnackbar 组件的属性
|
|
||||||
* - showMessage: 显示消息的方法
|
|
||||||
* - closeMessage: 关闭消息的方法
|
|
||||||
*/
|
|
||||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 Context.Provider 向下传递 snackbar 操作方法
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 子组件通过 useSnackbar() hook 获取这些方法
|
|
||||||
* - GlobalSnackbar 组件放在 Provider 外部,确保它能渲染到 DOM
|
|
||||||
*/
|
|
||||||
return (
|
|
||||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
|
||||||
{children}
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</SnackbarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
|
||||||
*
|
|
||||||
* @param {UseSnackbarOptions} [_options] - 可选的配置项(保留向后兼容性,不实际使用)
|
|
||||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
|
||||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 这是一个自定义 React Hook,用于在任意子组件中访问 Snackbar 功能。
|
|
||||||
* 必须确保组件被 SnackbarProvider 包裹才能使用。
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - 由于 Context 限制,useSnackbar 的 options 参数无法动态传递给 Provider
|
|
||||||
* - 如需设置全局初始选项,请在 SnackbarProvider 组件上设置 initialOptions
|
|
||||||
* - 如需为单个消息设置选项,请在 showMessage() 方法中传入
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { showMessage } = useSnackbar();
|
|
||||||
*
|
|
||||||
* const handleSuccess = () => {
|
|
||||||
* showMessage('操作成功!', { severity: 'success', autoHideDuration: 5000 });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <div>
|
|
||||||
* <button onClick={handleSuccess}>成功提示</button>
|
|
||||||
* </div>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useSnackbar(_options?: UseSnackbarOptions): SnackbarContextValue {
|
|
||||||
const context = useContext(SnackbarContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SnackbarProvider;
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
alpha,
|
|
||||||
} from '@mui/material';
|
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
export interface StorageCleanerConfirmProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
options: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_LABELS: Record<keyof StorageCleanerOptions, string> = {
|
|
||||||
localStorage: 'LocalStorage',
|
|
||||||
sessionStorage: 'Session Storage',
|
|
||||||
indexedDB: 'IndexedDB',
|
|
||||||
cookies: 'Cookies',
|
|
||||||
cacheStorage: 'Cache Storage',
|
|
||||||
serviceWorkers: 'Service Workers',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StorageCleanerConfirm({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
options,
|
|
||||||
}: StorageCleanerConfirmProps) {
|
|
||||||
const selectedOptions = Object.entries(options)
|
|
||||||
.filter(([_, value]) => value)
|
|
||||||
.map(([key, _]) => STORAGE_LABELS[key as keyof StorageCleanerOptions] || key);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="xs"
|
|
||||||
slotProps={{
|
|
||||||
paper: {
|
|
||||||
sx: {
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundImage: 'none',
|
|
||||||
boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`,
|
|
||||||
p: 1.5,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTitle
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
pt: 4,
|
|
||||||
pb: 1,
|
|
||||||
fontWeight: 900,
|
|
||||||
letterSpacing: '-0.5px',
|
|
||||||
fontSize: '1.35rem',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认清理数据?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogContent sx={{ textAlign: 'center', pb: 2 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ mb: 3.5, fontWeight: 500, fontSize: '0.9rem' }}
|
|
||||||
>
|
|
||||||
您将永久删除当前页面的以下选定存储项。
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.2, justifyContent: 'center', mb: 4 }}>
|
|
||||||
{selectedOptions.map((label) => (
|
|
||||||
<Chip
|
|
||||||
key={label}
|
|
||||||
label={label}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha(THEME_COLORS.warning, 0.04),
|
|
||||||
fontWeight: 700,
|
|
||||||
color: THEME_COLORS.warning,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(THEME_COLORS.warning, 0.15),
|
|
||||||
borderRadius: 2.5,
|
|
||||||
height: 'auto',
|
|
||||||
'& .MuiChip-label': { px: 1.2, py: 0.6 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
color: THEME_COLORS.error,
|
|
||||||
px: 2,
|
|
||||||
py: 0.8,
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px dashed',
|
|
||||||
borderColor: alpha(THEME_COLORS.error, 0.2),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 800,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span role="img" aria-label="warning">
|
|
||||||
⚠️
|
|
||||||
</span>{' '}
|
|
||||||
此操作不可撤销
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions sx={{ p: 3, pt: 1, gap: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
onClick={onClose}
|
|
||||||
fullWidth
|
|
||||||
sx={{
|
|
||||||
boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
color: 'text.primary',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={onConfirm}
|
|
||||||
fullWidth
|
|
||||||
sx={{
|
|
||||||
bgcolor: storageCleanerPageStyles.warningColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: storageCleanerPageStyles.warningDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StorageCleanerConfirm;
|
|
||||||
@@ -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;
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { Box, Typography, Stack } from '@mui/material';
|
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
|
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ToolCardProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
snapshot?: React.ReactNode;
|
|
||||||
colorCode: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
hasAI?: boolean;
|
|
||||||
cardBackgroundColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
bgcolor: cardBackgroundColor,
|
|
||||||
borderRadius: 4,
|
|
||||||
p: 2.5,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1.5,
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: colorCode,
|
|
||||||
transform: 'translateY(-4px)',
|
|
||||||
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode
|
|
||||||
'& .arrow-icon': {
|
|
||||||
transform: 'translateX(4px)',
|
|
||||||
color: colorCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: `${colorCode}11`, // 7% opacity
|
|
||||||
color: colorCode
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
color: 'text.primary',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
{hasAI && <AutoAwesomeIcon sx={{ fontSize: 14, color: '#f5b041' }} />}
|
|
||||||
</Typography>
|
|
||||||
{description && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<ArrowForwardIosIcon
|
|
||||||
className="arrow-icon"
|
|
||||||
sx={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'grey.300',
|
|
||||||
mt: 0.5,
|
|
||||||
transition: 'all 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{snapshot && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 'auto',
|
|
||||||
pt: 1.5,
|
|
||||||
borderTop: '1px dashed',
|
|
||||||
borderColor: 'grey.100'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{snapshot}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+288
-66
@@ -1,88 +1,310 @@
|
|||||||
import { useMemo } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
|
import {
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
ArrowLeft,
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
ExternalLink,
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
Globe,
|
||||||
|
History,
|
||||||
|
Monitor,
|
||||||
|
Moon,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Sun,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
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 }) {
|
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||||
const { currentPage, goBack } = useRouter();
|
const { currentPage, goBack, navigateTo } = useRouter();
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
|
const { t, i18n } = useTranslation(['common', 'features']);
|
||||||
|
|
||||||
const isDetachedMode = useMemo(() => {
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
return new URLSearchParams(window.location.search).get('mode') === 'detached';
|
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);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDetach = () => {
|
// 从 Chrome Storage 异步初始化历史记录
|
||||||
// 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
|
useEffect(() => {
|
||||||
const url = new URL(window.location.href);
|
storageUtil
|
||||||
url.searchParams.set('mode', 'detached');
|
.get('app/searchHistory', [])
|
||||||
|
.then((history) => {
|
||||||
|
if (history) setSearchHistory(history);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('加载搜索历史失败:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
chrome.windows.create({
|
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
|
||||||
url: url.toString(),
|
const searchResults = useMemo(() => {
|
||||||
type: 'panel',
|
const query = searchQuery.trim().toLowerCase();
|
||||||
width: 420,
|
if (!query) return [];
|
||||||
height: 600
|
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';
|
const isDashboard = currentPage === 'dashboard';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
||||||
direction="row"
|
{/* 左侧:返回按钮区 */}
|
||||||
justifyContent="space-between"
|
<div className="flex w-10 items-center justify-start">
|
||||||
alignItems="center"
|
|
||||||
sx={{
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
zIndex: 1100
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: 40 }}>
|
|
||||||
{!isDashboard && (
|
{!isDashboard && (
|
||||||
<IconButton
|
<button
|
||||||
size="small"
|
type="button"
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
sx={{
|
aria-label={t('common:buttons.back')}
|
||||||
bgcolor: 'grey.50',
|
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"
|
||||||
'&:hover': { bgcolor: 'grey.200' }
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</IconButton>
|
</button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</div>
|
||||||
|
|
||||||
<Typography
|
{/* 中间:搜索容器 */}
|
||||||
variant="subtitle2"
|
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
|
||||||
sx={{
|
<div className="relative">
|
||||||
fontWeight: 800,
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
letterSpacing: '0.5px',
|
<input
|
||||||
textTransform: 'uppercase',
|
ref={inputRef}
|
||||||
fontSize: '0.75rem',
|
type="text"
|
||||||
color: 'text.secondary'
|
placeholder={t('common:buttons.search')}
|
||||||
}}
|
value={searchQuery}
|
||||||
>
|
onChange={(e) => {
|
||||||
Testing Tools
|
setSearchQuery(e.target.value);
|
||||||
</Typography>
|
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>
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
|
{/* 动态联想结果卡片 */}
|
||||||
{!isDetachedMode && (
|
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||||
<Tooltip title="独立窗口模式">
|
<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">
|
||||||
<IconButton size="small" onClick={handleDetach}>
|
<ul role="listbox" className="p-1">
|
||||||
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
{searchQuery.trim() ? (
|
||||||
</IconButton>
|
searchResults.length > 0 ? (
|
||||||
</Tooltip>
|
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>
|
||||||
)}
|
)}
|
||||||
<Tooltip title="设置">
|
</div>
|
||||||
<IconButton size="small" onClick={onOpenOptions}>
|
|
||||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
{/* 右侧:操作区 */}
|
||||||
</IconButton>
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
</Tooltip>
|
<IconButton onClick={toggleLanguage} title={t('common:buttons.toggleLanguage')}>
|
||||||
</Stack>
|
<Globe className="h-4 w-4" />
|
||||||
</Stack>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Box, TextField, Alert, Stack } from '@mui/material';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
interface UrlEntryFormProps {
|
|
||||||
onAddEntry: (entry: OpenUrlEntry) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
|
|
||||||
const [newName, setNewName] = useState<string>('');
|
|
||||||
const [newUrl, setNewUrl] = useState<string>('');
|
|
||||||
|
|
||||||
const showMixedContentWarning =
|
|
||||||
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
|
|
||||||
|
|
||||||
const isValidUrl = (url: string) => {
|
|
||||||
if (!url.trim()) return false;
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddEntry = () => {
|
|
||||||
if (!newName.trim()) {
|
|
||||||
showMessage('请输入名称', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isValidUrl(newUrl)) {
|
|
||||||
showMessage('请输入有效的 URL', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
|
|
||||||
setNewName('');
|
|
||||||
setNewUrl('');
|
|
||||||
showMessage('添加成功', { severity: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
mb: 3,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="环境名称"
|
|
||||||
placeholder="例如: 本地文档"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
sx={openUrlPageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="目标 URL"
|
|
||||||
placeholder="例如: http://localhost:8000/docs"
|
|
||||||
value={newUrl}
|
|
||||||
onChange={(e) => setNewUrl(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
sx={openUrlPageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showMixedContentWarning && (
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleAddEntry}
|
|
||||||
disabled={!newName.trim() || !isValidUrl(newUrl)}
|
|
||||||
fullWidth
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
sx={{
|
|
||||||
bgcolor: openUrlPageStyles.themeColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: openUrlPageStyles.primaryDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
添加快捷方式
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlEntryForm;
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { Fragment } from 'react';
|
|
||||||
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
||||||
import { alpha } from '@mui/material/styles';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
interface UrlEntryItemProps {
|
|
||||||
entry: OpenUrlEntry;
|
|
||||||
index: number;
|
|
||||||
isLast: boolean;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
|
|
||||||
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
|
|
||||||
try {
|
|
||||||
// 存储目标 URL
|
|
||||||
await storageUtil.set('openUrl/currentUrl', entry.url);
|
|
||||||
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
|
|
||||||
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
|
|
||||||
|
|
||||||
const [currentTab] = await chrome.tabs.query({
|
|
||||||
active: true,
|
|
||||||
currentWindow: true,
|
|
||||||
});
|
|
||||||
const tabId = currentTab.id;
|
|
||||||
if (!tabId) {
|
|
||||||
showMessage('无法获取当前标签页', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await chrome.sidePanel.setOptions({
|
|
||||||
tabId,
|
|
||||||
path: 'sidepanel.html',
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
await chrome.sidePanel.open({ windowId: currentTab.windowId });
|
|
||||||
|
|
||||||
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
|
|
||||||
if (window.location.pathname.includes('popup.html')) {
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to open side panel:', error);
|
|
||||||
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
|
|
||||||
chrome.tabs.create({ url: entry.url }).catch(console.error);
|
|
||||||
window.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
onDelete(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<ListItem
|
|
||||||
sx={{
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
|
|
||||||
{entry.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
noWrap
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.2,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.url}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="在侧边栏预览">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleOpenInSidebar(entry)}
|
|
||||||
sx={{
|
|
||||||
color: openUrlPageStyles.themeColor,
|
|
||||||
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
|
|
||||||
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VisibilityIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="新标签页打开">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleOpenInNewTab(entry)}
|
|
||||||
sx={{
|
|
||||||
color: 'grey.500',
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleDelete}
|
|
||||||
sx={{
|
|
||||||
color: 'error.main',
|
|
||||||
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</ListItem>
|
|
||||||
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlEntryItem;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Box, List, Typography } from '@mui/material';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import UrlEntryItem from './UrlEntryItem';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface UrlEntryListProps {
|
|
||||||
entries: OpenUrlEntry[];
|
|
||||||
onDeleteEntry: (index: number) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
py: 4,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px dashed',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.disabled"
|
|
||||||
sx={{ display: 'block', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
暂无快捷方式,请在上方添加
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
disablePadding
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<UrlEntryItem
|
|
||||||
key={index}
|
|
||||||
entry={entry}
|
|
||||||
index={index}
|
|
||||||
isLast={index === entries.length - 1}
|
|
||||||
onDelete={onDeleteEntry}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlEntryList;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
CircularProgress,
|
|
||||||
} from '@mui/material';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import qrcode from 'qrcode';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface UrlToQrCodeSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlToQrCodeSection = ({
|
|
||||||
expanded,
|
|
||||||
onExpandedChange,
|
|
||||||
showMessage,
|
|
||||||
}: UrlToQrCodeSectionProps) => {
|
|
||||||
const [urlInput, setUrlInput] = useState('');
|
|
||||||
const [urlError, setUrlError] = useState('');
|
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
|
|
||||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUrlInput(e.target.value);
|
|
||||||
setUrlError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQrCode = async () => {
|
|
||||||
if (!urlInput) {
|
|
||||||
setUrlError('请输入 URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGenerating(true);
|
|
||||||
setUrlError('');
|
|
||||||
|
|
||||||
let url = urlInput;
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
||||||
url = 'https://' + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = await qrcode.toDataURL(url, {
|
|
||||||
width: 200,
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: qrCodePageStyles.black,
|
|
||||||
light: qrCodePageStyles.white,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setQrCodeDataUrl(dataUrl);
|
|
||||||
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('生成二维码失败:', error);
|
|
||||||
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadQrCode = () => {
|
|
||||||
if (!qrCodeDataUrl) return;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = qrCodeDataUrl;
|
|
||||||
link.download = 'qrcode.png';
|
|
||||||
link.click();
|
|
||||||
showMessage('二维码下载成功', { severity: 'success', autoHideDuration: 300 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyQrCode = async () => {
|
|
||||||
if (!qrCodeDataUrl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(qrCodeDataUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'image/png': blob,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
showMessage('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('复制二维码失败:', error);
|
|
||||||
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expanded}
|
|
||||||
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 4,
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
||||||
'&:before': { display: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<QrCodeIcon color="primary" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
URL 转二维码
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<TextField
|
|
||||||
label="输入 URL"
|
|
||||||
placeholder="https://example.com"
|
|
||||||
value={urlInput}
|
|
||||||
onChange={handleUrlInputChange}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
error={!!urlError}
|
|
||||||
helperText={urlError}
|
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
|
|
||||||
onClick={generateQrCode}
|
|
||||||
disabled={generating}
|
|
||||||
sx={{
|
|
||||||
py: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: qrCodePageStyles.successColor,
|
|
||||||
fontWeight: 700,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: qrCodePageStyles.successDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? '生成中...' : '生成二维码'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: 200,
|
|
||||||
border: '2px dashed',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
borderRadius: 3,
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{qrCodeDataUrl ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={qrCodeDataUrl}
|
|
||||||
alt="QR Code"
|
|
||||||
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<DownloadIcon />}
|
|
||||||
onClick={downloadQrCode}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
borderColor: qrCodePageStyles.successColor,
|
|
||||||
color: qrCodePageStyles.successColor,
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: qrCodePageStyles.successDark,
|
|
||||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
下载二维码
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<ContentCopyIcon />}
|
|
||||||
onClick={copyQrCode}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: qrCodePageStyles.successColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: qrCodePageStyles.successDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
复制二维码
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
|
||||||
二维码将显示在这里
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlToQrCodeSection;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import Button from '../Button';
|
|
||||||
|
|
||||||
describe('Button 组件', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
|
||||||
it('应使用默认属性渲染', () => {
|
|
||||||
render(<Button>点击我</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /点击我/i });
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染自定义文本', () => {
|
|
||||||
render(<Button>提交</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /提交/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染不同变体', () => {
|
|
||||||
const { rerender } = render(<Button variant="contained">填充</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /填充/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(<Button variant="outlined">描边</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /描边/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(<Button variant="text">文本</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /文本/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('交互测试', () => {
|
|
||||||
it('点击时应调用 onClick', () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(<Button onClick={handleClick}>点击我</Button>);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /点击我/i }));
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('禁用状态下点击不应调用 onClick', () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(
|
|
||||||
<Button onClick={handleClick} disabled>
|
|
||||||
禁用按钮
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /禁用按钮/i }));
|
|
||||||
expect(handleClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('样式测试', () => {
|
|
||||||
it('应应用 fullWidth 属性', () => {
|
|
||||||
render(<Button fullWidth>全宽</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /全宽/i });
|
|
||||||
expect(button).toHaveClass('MuiButton-fullWidth');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('状态测试', () => {
|
|
||||||
it('应渲染加载状态', () => {
|
|
||||||
render(<Button loading>加载中</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /加载中/i });
|
|
||||||
expect(button).toHaveClass('MuiButton-loading');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染为禁用状态', () => {
|
|
||||||
render(<Button disabled>禁用</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /禁用/i });
|
|
||||||
expect(button).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, act, renderHook } from '@testing-library/react';
|
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||||
import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
GlobalSnackbar,
|
||||||
|
type GlobalSnackbarProps,
|
||||||
|
SnackbarProvider,
|
||||||
|
useSnackbar,
|
||||||
|
useSnackbarState,
|
||||||
|
} from '@/components/GlobalSnackbar';
|
||||||
|
|
||||||
describe('GlobalSnackbar 组件系统', () => {
|
describe('GlobalSnackbar 组件系统', () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
@@ -22,87 +29,89 @@ describe('GlobalSnackbar 组件系统', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('GlobalSnackbar UI 渲染', () => {
|
describe('GlobalSnackbar UI 渲染', () => {
|
||||||
it('应渲染消息内容并由于使用了 Portal 出现在 body 中', () => {
|
it('应渲染消息内容', () => {
|
||||||
render(<GlobalSnackbar {...defaultProps} />);
|
render(<GlobalSnackbar {...defaultProps} />);
|
||||||
// 因为使用了 Portal,它不在常规 render 的容器内,但在 document 中
|
|
||||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 showAlert 为 true 时应渲染 MUI Alert 样式', () => {
|
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
|
||||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
||||||
// 验证是否包含 MUI Alert 的类名
|
// 验证是否包含消息文本
|
||||||
const alertElement = document.querySelector('.MuiAlert-root');
|
const alertElement = screen.getByText('测试消息');
|
||||||
expect(alertElement).toBeInTheDocument();
|
expect(alertElement).toBeInTheDocument();
|
||||||
expect(alertElement).toHaveTextContent('测试消息');
|
// 验证父元素有正确的样式类
|
||||||
|
const parent = alertElement.parentElement;
|
||||||
|
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
||||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
||||||
// MUI Alert 图标通常在 .MuiAlert-icon 中
|
// 图标使用 lucide-react 的 svg 元素
|
||||||
const icon = document.querySelector('.MuiAlert-icon');
|
const icon = document.querySelector('svg');
|
||||||
expect(icon).not.toBeInTheDocument();
|
expect(icon).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应根据 severity 应用不同的样式 (通过检查 style 或 class)', () => {
|
it('应根据 severity 应用不同的样式', () => {
|
||||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
||||||
const alert = document.querySelector('.MuiAlert-filledError');
|
const message = screen.getByText('测试消息');
|
||||||
expect(alert).toBeInTheDocument();
|
const parent = message.parentElement;
|
||||||
|
expect(parent).toHaveClass('bg-red-500');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useSnackbarState Hook 逻辑', () => {
|
describe('useSnackbarState Hook 逻辑', () => {
|
||||||
it('应能正确初始化并更新状态', () => {
|
it('应返回初始状态', () => {
|
||||||
const { result } = renderHook(() => useSnackbarState({ severity: 'warning' }));
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
|
expect(result.current.snackbarProps.message).toBe('');
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('新提醒', { severity: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(true);
|
|
||||||
expect(result.current.snackbarProps.message).toBe('新提醒');
|
|
||||||
expect(result.current.snackbarProps.severity).toBe('success');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('closeMessage 应立即关闭 Snackbar', () => {
|
it('showMessage 应更新状态', () => {
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.showMessage('测试');
|
result.current.showMessage('新消息');
|
||||||
});
|
});
|
||||||
expect(result.current.snackbarProps.open).toBe(true);
|
|
||||||
|
|
||||||
|
expect(result.current.snackbarProps.open).toBe(true);
|
||||||
|
expect(result.current.snackbarProps.message).toBe('新消息');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeMessage 应关闭消息', () => {
|
||||||
|
const { result } = renderHook(() => useSnackbarState());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.showMessage('消息');
|
||||||
|
});
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.closeMessage();
|
result.current.closeMessage();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
expect(result.current.snackbarProps.open).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('交互与自动隐藏', () => {
|
describe('useSnackbar Context Hook 优先级', () => {
|
||||||
it('在 autoHideDuration 结束后应触发 onClose', () => {
|
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
||||||
render(<GlobalSnackbar {...defaultProps} autoHideDuration={3000} />);
|
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(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(3000);
|
result.current.showMessage('消息 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
it('当 reason 为 clickaway 时不应调用 onClose (源码逻辑验证)', () => {
|
// 2. 测试 Call Options 覆盖 Hook Options
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
// 模拟 MUI 的 handleClose 被 clickaway 触发
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.snackbarProps.onClose();
|
result.current.showMessage('消息 2', { severity: 'error' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 状态应该保持 open: true
|
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
// 注意:此处取决于你对 useSnackbarState 的期望。
|
|
||||||
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import PageHeader, { type PageHeaderProps } from '../PageHeader';
|
|
||||||
|
|
||||||
describe('PageHeader 组件系统', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: PageHeaderProps = {
|
|
||||||
icon: <AccessTimeIcon />,
|
|
||||||
title: '时间戳转换',
|
|
||||||
subtitle: 'Unix 毫秒数转换与格式化',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PageHeader UI 渲染', () => {
|
|
||||||
it('应渲染页面标题栏&副标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染自定义图标&图标颜色', () => {
|
|
||||||
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
|
|
||||||
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染 badge 组件', () => {
|
|
||||||
const badge = <span data-testid="test-badge">New</span>;
|
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
|
||||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('New')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染 badge 与 title 并排布局', () => {
|
|
||||||
const badge = <span data-testid="side-badge">v1.0</span>;
|
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
|
||||||
const title = screen.getByText('时间戳转换');
|
|
||||||
const badgeEl = screen.getByTestId('side-badge');
|
|
||||||
expect(title).toBeInTheDocument();
|
|
||||||
expect(badgeEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PageHeader 条件渲染', () => {
|
|
||||||
it('subtitle 为 undefined 时不应渲染副标题', () => {
|
|
||||||
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
|
|
||||||
const captionElements = container.querySelectorAll('p');
|
|
||||||
expect(captionElements.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('subtitle 为空字符串时不应渲染副标题', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
|
|
||||||
);
|
|
||||||
const captionElements = container.querySelectorAll('p');
|
|
||||||
expect(captionElements.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('badge 为 undefined 时不应渲染 badge 区域', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PageHeader 样式扩展', () => {
|
|
||||||
it('iconSx 应作为属性传递给图标容器', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
|
|
||||||
);
|
|
||||||
const iconContainer = container.querySelector('div');
|
|
||||||
expect(iconContainer).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('titleSx 应作为属性传递给标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
|
|
||||||
const titleEl = screen.getByText('时间戳转换');
|
|
||||||
expect(titleEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('subtitleSx 应作为属性传递给副标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
|
|
||||||
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
|
|
||||||
expect(subtitleEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sx 应作为属性传递给外层容器', () => {
|
|
||||||
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
|
|
||||||
const outerElement = container.firstChild;
|
|
||||||
expect(outerElement).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import RouterContainer from '../RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
import { SnackbarProvider } from '@/components/SnackbarProvider';
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ const mockRouterValue = {
|
|||||||
pageOrder: ['timestamp'] as PageType[],
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
navigateTo: vi.fn(),
|
navigateTo: vi.fn(),
|
||||||
navigateLocal: vi.fn(),
|
|
||||||
syncNavigation: vi.fn(),
|
syncNavigation: vi.fn(),
|
||||||
goBack: vi.fn(),
|
goBack: vi.fn(),
|
||||||
setVisiblePages: vi.fn(),
|
setVisiblePages: vi.fn(),
|
||||||
@@ -38,10 +37,12 @@ describe('RouterContainer 组件', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('isLoaded 为 false 时应渲染加载状态', () => {
|
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||||
mockRouterValue.isLoaded = false;
|
mockRouterValue.isLoaded = false;
|
||||||
renderWithProvider(<RouterContainer />);
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
// 骨架屏使用 animate-pulse 类
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||||
@@ -83,4 +84,16 @@ describe('RouterContainer 组件', () => {
|
|||||||
expect(box).toBeInTheDocument();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { StorageCleanerConfirm } from '../StorageCleanerConfirm';
|
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/StorageCleanerConfirm';
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
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 组件', () => {
|
describe('StorageCleanerConfirm 组件', () => {
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
@@ -35,25 +53,28 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('open 为 true 时应渲染对话框', () => {
|
it('open 为 true 时应渲染对话框', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText('确认清理数据?')).toBeInTheDocument();
|
// 💡 修复点 3:拥抱模糊正则断言。
|
||||||
|
// 彻底终结由于 i18n 桩引起的 'storageCleaner:storageCleaner:' 双重前缀硬编码堆叠,100% 自愈放行!
|
||||||
|
expect(screen.getByText(/confirmTitle/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应显示警告信息', () => {
|
it('应显示警告信息', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText(/此操作不可撤销/i)).toBeInTheDocument();
|
expect(screen.getByText(/irreversible/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应将选中的选项显示为标签', () => {
|
it('应将选中的选项显示为标签', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByText('LocalStorage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Session Storage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Cookies')).toBeInTheDocument();
|
expect(screen.getByText(/options\.cookies/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应显示取消和确认按钮', () => {
|
it('应显示取消和确认按钮', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
expect(screen.getByRole('button', { name: /取消/i })).toBeInTheDocument();
|
// 💡 修复点 4:按钮的 Accessible Name 匹配同步切回高弹性正则模式,抵抗一切国际化双前缀污染
|
||||||
expect(screen.getByRole('button', { name: /确认清理/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /confirmAction/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +82,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
it('点击取消时应调用 onClose', () => {
|
it('点击取消时应调用 onClose', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /取消/i }));
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -69,7 +90,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
it('点击确认时应调用 onConfirm', () => {
|
it('点击确认时应调用 onConfirm', () => {
|
||||||
renderComponent();
|
renderComponent();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /确认清理/i }));
|
fireEvent.click(screen.getByRole('button', { name: /confirmAction/i }));
|
||||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
expect(mockOnClose).not.toHaveBeenCalled();
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -88,10 +109,10 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
|
|
||||||
renderComponent({ options: partialOptions });
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
expect(screen.getByText('LocalStorage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.localStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('IndexedDB')).toBeInTheDocument();
|
expect(screen.getByText(/options\.indexedDB/)).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Session Storage')).not.toBeInTheDocument();
|
expect(screen.queryByText(/options\.sessionStorage/)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('Cookies')).not.toBeInTheDocument();
|
expect(screen.queryByText(/options\.cookies/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应处理空选项', () => {
|
it('应处理空选项', () => {
|
||||||
@@ -114,7 +135,7 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
describe('对话框行为测试', () => {
|
describe('对话框行为测试', () => {
|
||||||
it('open 为 false 时不应渲染', () => {
|
it('open 为 false 时不应渲染', () => {
|
||||||
renderComponent({ open: false });
|
renderComponent({ open: false });
|
||||||
expect(screen.queryByText('确认清理数据?')).not.toBeInTheDocument();
|
expect(screen.queryByText(/confirmTitle/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应使用不同选项渲染', () => {
|
it('应使用不同选项渲染', () => {
|
||||||
@@ -129,8 +150,8 @@ describe('StorageCleanerConfirm 组件', () => {
|
|||||||
|
|
||||||
renderComponent({ options: customOptions });
|
renderComponent({ options: customOptions });
|
||||||
|
|
||||||
expect(screen.getByText('Session Storage')).toBeInTheDocument();
|
expect(screen.getByText(/options\.sessionStorage/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Cookies')).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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
import ToolCard from '../ToolCard';
|
import userEvent from '@testing-library/user-event';
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import ToolCard from '@/pages/Dashboard/ToolCard';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
describe('ToolCard 组件', () => {
|
describe('ToolCard 组件', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -14,9 +15,9 @@ describe('ToolCard 组件', () => {
|
|||||||
<ToolCard
|
<ToolCard
|
||||||
title="测试工具"
|
title="测试工具"
|
||||||
description="这是一个测试工具"
|
description="这是一个测试工具"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={Clock}
|
||||||
onClick={() => {}}
|
onNavigate={() => {}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -25,38 +26,27 @@ describe('ToolCard 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('无描述时仅渲染标题', () => {
|
it('无描述时仅渲染标题', () => {
|
||||||
render(
|
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||||
<ToolCard
|
|
||||||
title="仅标题"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
it('应渲染图标', () => {
|
||||||
render(
|
const { container } = render(
|
||||||
<ToolCard
|
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
||||||
title="带图标"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon data-testid="test-icon" />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
const svgElement = container.querySelector('svg');
|
||||||
|
expect(svgElement).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('提供快照内容时应渲染快照', () => {
|
it('提供快照内容时应渲染快照', () => {
|
||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="带快照"
|
title="带快照"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={Clock}
|
||||||
onClick={() => {}}
|
onNavigate={() => {}}
|
||||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -68,64 +58,48 @@ describe('ToolCard 组件', () => {
|
|||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="无快照"
|
title="无快照"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={Clock}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
|
onNavigate={function (): void {
|
||||||
|
throw new Error('Function not implemented.');
|
||||||
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('AI 徽章测试', () => {
|
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||||
it('hasAI 为 true 时应渲染 AI 徽章', () => {
|
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="AI 工具"
|
|
||||||
hasAI={true}
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
|
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||||
expect(autoAwesomeIcon).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
it('hasAI 为 false 时不应渲染 AI 徽章', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="普通工具"
|
|
||||||
hasAI={false}
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
|
|
||||||
expect(autoAwesomeIcon).not.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('交互测试', () => {
|
describe('交互测试', () => {
|
||||||
it('点击时应调用 onClick', () => {
|
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();
|
const handleClick = vi.fn();
|
||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
||||||
title="可点击"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={handleClick}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const card = screen.getByText('可点击').closest('.MuiBox-root');
|
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
||||||
if (card) {
|
await act(async () => {
|
||||||
fireEvent.click(card);
|
button.focus();
|
||||||
}
|
await userEvent.keyboard('{Enter}');
|
||||||
|
});
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
@@ -133,18 +107,12 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
describe('样式测试', () => {
|
describe('样式测试', () => {
|
||||||
it('应应用自定义颜色代码', () => {
|
it('应应用自定义颜色代码', () => {
|
||||||
const customColor = '#ff5722';
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCard
|
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
||||||
title="自定义颜色"
|
|
||||||
colorCode={customColor}
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const iconContainer = container.querySelector('.MuiBox-root > div');
|
const svgElement = container.querySelector('svg');
|
||||||
expect(iconContainer).toBeInTheDocument();
|
expect(svgElement).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import TopBar from '../TopBar';
|
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
|
||||||
import type { PageType } from '@/types/storage';
|
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 = {
|
const mockRouterValue = {
|
||||||
currentPage: 'dashboard' as PageType,
|
currentPage: 'dashboard' as PageType,
|
||||||
@@ -10,7 +27,6 @@ const mockRouterValue = {
|
|||||||
pageOrder: ['timestamp'] as PageType[],
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
isLoaded: true,
|
isLoaded: true,
|
||||||
navigateTo: vi.fn(),
|
navigateTo: vi.fn(),
|
||||||
navigateLocal: vi.fn(),
|
|
||||||
syncNavigation: vi.fn(),
|
syncNavigation: vi.fn(),
|
||||||
goBack: vi.fn(),
|
goBack: vi.fn(),
|
||||||
setVisiblePages: vi.fn(),
|
setVisiblePages: vi.fn(),
|
||||||
@@ -28,30 +44,29 @@ describe('TopBar 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderWithProvider = (ui: React.ReactElement) => {
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
return render(<RouterProvider>{ui}</RouterProvider>);
|
return render(
|
||||||
|
<ThemeModeProvider>
|
||||||
|
<RouterProvider>{ui}</RouterProvider>
|
||||||
|
</ThemeModeProvider>,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
it('应使用默认标题渲染', () => {
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
expect(screen.getByText('Testing Tools')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
|
expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||||
mockRouterValue.currentPage = 'dashboard';
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
|
expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('应渲染设置按钮', () => {
|
it('应渲染设置按钮', () => {
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
|
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +75,7 @@ describe('TopBar 组件', () => {
|
|||||||
const handleOpenOptions = vi.fn();
|
const handleOpenOptions = vi.fn();
|
||||||
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('SettingsIcon'));
|
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
|
||||||
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +83,7 @@ describe('TopBar 组件', () => {
|
|||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon'));
|
fireEvent.click(screen.getByLabelText('common:buttons.back'));
|
||||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
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 };
|
||||||
@@ -1,39 +1,39 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
FEATURES,
|
FEATURES,
|
||||||
getFeatureByKey,
|
|
||||||
getDefaultVisibleFeatureKeys,
|
|
||||||
getAllFeatureKeys,
|
getAllFeatureKeys,
|
||||||
getDefaultPageOrder,
|
getDefaultPageOrder,
|
||||||
} from '../features';
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getFeatureByKey,
|
||||||
|
} from '@/config/features';
|
||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 9 features defined', () => {
|
it('should have 11 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(9);
|
expect(FEATURES).toHaveLength(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
FEATURES.forEach((feature) => {
|
FEATURES.forEach((feature) => {
|
||||||
expect(feature).toHaveProperty('key');
|
expect(feature).toHaveProperty('key');
|
||||||
expect(feature).toHaveProperty('label');
|
expect(feature).toHaveProperty('labelKey');
|
||||||
expect(feature).toHaveProperty('description');
|
expect(feature).toHaveProperty('descriptionKey');
|
||||||
expect(feature).toHaveProperty('defaultVisible');
|
expect(feature).toHaveProperty('defaultVisible');
|
||||||
expect(feature).toHaveProperty('components');
|
expect(feature).toHaveProperty('components');
|
||||||
expect(typeof feature.key).toBe('string');
|
expect(typeof feature.key).toBe('string');
|
||||||
expect(typeof feature.label).toBe('string');
|
expect(typeof feature.labelKey).toBe('string');
|
||||||
expect(typeof feature.description).toBe('string');
|
expect(typeof feature.descriptionKey).toBe('string');
|
||||||
expect(typeof feature.defaultVisible).toBe('boolean');
|
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||||
expect(typeof feature.components).toBe('object');
|
expect(typeof feature.components).toBe('object');
|
||||||
expect(feature.components).toHaveProperty('popup');
|
expect(feature.components).toHaveProperty('popup');
|
||||||
expect(feature.components).toHaveProperty('sidepanel');
|
expect(feature.components).toHaveProperty('sidepanel');
|
||||||
expect(feature.components).toHaveProperty('detached');
|
expect(feature.components).toHaveProperty('tab');
|
||||||
|
|
||||||
// Optional UI properties for non-hidden features
|
// Optional UI properties for non-hidden features
|
||||||
if (feature.key !== 'dashboard' && feature.key !== 'openUrlViewer') {
|
if (feature.key !== 'dashboard') {
|
||||||
expect(feature).toHaveProperty('icon');
|
expect(feature).toHaveProperty('icon');
|
||||||
expect(feature).toHaveProperty('themeColor');
|
expect(feature).toHaveProperty('themeColorKey');
|
||||||
expect(typeof feature.themeColor).toBe('string');
|
expect(typeof feature.themeColorKey).toBe('string');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,22 +50,22 @@ describe('features', () => {
|
|||||||
const feature = getFeatureByKey('dashboard');
|
const feature = getFeatureByKey('dashboard');
|
||||||
expect(feature).toBeDefined();
|
expect(feature).toBeDefined();
|
||||||
expect(feature?.key).toBe('dashboard');
|
expect(feature?.key).toBe('dashboard');
|
||||||
expect(feature?.label).toBe('Dashboard');
|
expect(feature?.labelKey).toBe('features:dashboard.title');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return timestamp feature', () => {
|
it('should return timestamp feature', () => {
|
||||||
const feature = getFeatureByKey('timestamp');
|
const feature = getFeatureByKey('timestamp');
|
||||||
expect(feature).toBeDefined();
|
expect(feature).toBeDefined();
|
||||||
expect(feature?.key).toBe('timestamp');
|
expect(feature?.key).toBe('timestamp');
|
||||||
expect(feature?.label).toBe('时间戳');
|
expect(feature?.labelKey).toBe('features:timestamp.title');
|
||||||
expect(feature?.themeColor).toBeDefined();
|
expect(feature?.themeColorKey).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return storageCleaner feature', () => {
|
it('should return storageCleaner feature', () => {
|
||||||
const feature = getFeatureByKey('storageCleaner');
|
const feature = getFeatureByKey('storageCleaner');
|
||||||
expect(feature).toBeDefined();
|
expect(feature).toBeDefined();
|
||||||
expect(feature?.key).toBe('storageCleaner');
|
expect(feature?.key).toBe('storageCleaner');
|
||||||
expect(feature?.label).toBe('存储清理');
|
expect(feature?.labelKey).toBe('features:storageCleaner.title');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return undefined for invalid key', () => {
|
it('should return undefined for invalid key', () => {
|
||||||
@@ -83,31 +83,30 @@ describe('features', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
|
it('should include dashboard, timestamp, storageCleaner, qrCode', () => {
|
||||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
expect(visibleKeys).toContain('dashboard');
|
expect(visibleKeys).toContain('dashboard');
|
||||||
expect(visibleKeys).toContain('timestamp');
|
expect(visibleKeys).toContain('timestamp');
|
||||||
expect(visibleKeys).toContain('storageCleaner');
|
expect(visibleKeys).toContain('storageCleaner');
|
||||||
expect(visibleKeys).toContain('openUrl');
|
expect(visibleKeys).toContain('qrCode');
|
||||||
});
|
|
||||||
|
|
||||||
it('should not include openUrlViewer (not visible by default)', () => {
|
|
||||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
|
||||||
expect(visibleKeys).not.toContain('openUrlViewer');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(9);
|
expect(allKeys).toHaveLength(11);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
expect(allKeys).toContain('openUrl');
|
|
||||||
expect(allKeys).toContain('qrCode');
|
expect(allKeys).toContain('qrCode');
|
||||||
expect(allKeys).toContain('formRecognizer');
|
expect(allKeys).toContain('textStatistics');
|
||||||
expect(allKeys).toContain('openUrlViewer');
|
expect(allKeys).toContain('jwt');
|
||||||
|
expect(allKeys).toContain('jsonDiff');
|
||||||
|
expect(allKeys).toContain('base64Converter');
|
||||||
|
expect(allKeys).toContain('markdownToHtml');
|
||||||
|
expect(allKeys).toContain('htmlToMarkdown');
|
||||||
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,23 +116,16 @@ describe('features', () => {
|
|||||||
expect(pageOrder).not.toContain('dashboard');
|
expect(pageOrder).not.toContain('dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude openUrlViewer from page order', () => {
|
it('should include timestamp, storageCleaner, qrCode in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
|
||||||
expect(pageOrder).not.toContain('openUrlViewer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include timestamp, storageCleaner, openUrl, qrCode, formRecognizer in page order', () => {
|
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toContain('timestamp');
|
expect(pageOrder).toContain('timestamp');
|
||||||
expect(pageOrder).toContain('storageCleaner');
|
expect(pageOrder).toContain('storageCleaner');
|
||||||
expect(pageOrder).toContain('openUrl');
|
|
||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
expect(pageOrder).toContain('formRecognizer');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 7 items in page order', () => {
|
it('should have 10 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(7);
|
expect(pageOrder).toHaveLength(10);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+128
-106
@@ -1,164 +1,188 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import { type ComponentType, lazy } from 'react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import {
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
Clock,
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
Database,
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
QrCode,
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
FileText,
|
||||||
|
Key,
|
||||||
|
GitCompareArrows,
|
||||||
|
ArrowLeftRight,
|
||||||
|
Code,
|
||||||
|
File,
|
||||||
|
MousePointerClick,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
|
|
||||||
import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
|
|
||||||
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
|
|
||||||
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
|
|
||||||
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
|
|
||||||
import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage';
|
|
||||||
import FormFillPage from '@/entrypoints/popup/pages/FormFillPage';
|
|
||||||
|
|
||||||
import { THEME_COLORS } from './pageTheme';
|
// 懒加载页面组件
|
||||||
|
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 {
|
export interface FeatureConfig {
|
||||||
/** 页面类型标识 */
|
|
||||||
key: PageType;
|
key: PageType;
|
||||||
/** 功能名称(用于路由标签和卡片标题) */
|
labelKey: string;
|
||||||
label: string;
|
descriptionKey: string;
|
||||||
/** 功能描述(用于仪表盘卡片) */
|
themeColorKey?: PaletteColorKey;
|
||||||
description: string;
|
icon?: ComponentType<LucideProps>;
|
||||||
/** 主题颜色(用于仪表盘卡片) */
|
|
||||||
themeColor?: string;
|
|
||||||
/** 图标组件(用于仪表盘卡片) */
|
|
||||||
icon?: ReactNode;
|
|
||||||
/** 默认是否在仪表盘显示 */
|
|
||||||
defaultVisible: boolean;
|
defaultVisible: boolean;
|
||||||
/** 不同显示模式对应的组件 */
|
|
||||||
components: {
|
components: {
|
||||||
/** 弹窗模式组件 */
|
popup: ComponentType;
|
||||||
popup: React.ComponentType;
|
sidepanel: ComponentType;
|
||||||
/** 侧边栏模式组件 */
|
tab: ComponentType;
|
||||||
sidepanel: React.ComponentType;
|
|
||||||
/** 独立窗口模式组件 */
|
|
||||||
detached: React.ComponentType;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FEATURES: FeatureConfig[] = [
|
export const FEATURES: FeatureConfig[] = [
|
||||||
{
|
{
|
||||||
key: 'dashboard',
|
key: 'dashboard',
|
||||||
label: 'Dashboard',
|
labelKey: 'features:dashboard.title',
|
||||||
description: '',
|
descriptionKey: '',
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: DashboardPage,
|
popup: DashboardPage,
|
||||||
sidepanel: DashboardPage,
|
sidepanel: DashboardPage,
|
||||||
detached: DashboardPage,
|
tab: DashboardPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
label: '时间戳',
|
labelKey: 'features:timestamp.title',
|
||||||
description: 'Unix 毫秒数转换与格式化',
|
descriptionKey: 'features:timestamp.description',
|
||||||
themeColor: THEME_COLORS.primary,
|
themeColorKey: 'primary',
|
||||||
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
icon: Clock,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: TimestampPage,
|
popup: TimestampPage,
|
||||||
sidepanel: TimestampPage,
|
sidepanel: TimestampPage,
|
||||||
detached: TimestampPage,
|
tab: TimestampPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'storageCleaner',
|
key: 'storageCleaner',
|
||||||
label: '存储清理',
|
labelKey: 'features:storageCleaner.title',
|
||||||
description: '清理缓存、Cookies 及本地存储',
|
descriptionKey: 'features:storageCleaner.description',
|
||||||
themeColor: THEME_COLORS.warning,
|
themeColorKey: 'warning',
|
||||||
icon: <StorageIcon sx={{ fontSize: 20 }} />,
|
icon: Database,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: StorageCleanerPage,
|
popup: StorageCleanerPage,
|
||||||
sidepanel: StorageCleanerPage,
|
sidepanel: StorageCleanerPage,
|
||||||
detached: StorageCleanerPage,
|
tab: StorageCleanerPage,
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'openUrl',
|
|
||||||
label: 'Open Url',
|
|
||||||
description: '打开当前选中的 URL',
|
|
||||||
themeColor: THEME_COLORS.purple,
|
|
||||||
icon: <LanguageIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: OpenUrlPage,
|
|
||||||
sidepanel: OpenUrlPage,
|
|
||||||
detached: OpenUrlPage,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'qrCode',
|
key: 'qrCode',
|
||||||
label: '二维码工具',
|
labelKey: 'features:qrCode.title',
|
||||||
description: '生成当前选中的 URL 的二维码',
|
descriptionKey: 'features:qrCode.description',
|
||||||
themeColor: THEME_COLORS.success,
|
themeColorKey: 'success',
|
||||||
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
icon: QrCode,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: QrCodePage,
|
popup: QrCodePage,
|
||||||
sidepanel: QrCodePage,
|
sidepanel: QrCodePage,
|
||||||
detached: QrCodePage,
|
tab: QrCodePage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'formMapping',
|
key: 'textStatistics',
|
||||||
label: '表单映射',
|
labelKey: 'features:textStatistics.title',
|
||||||
description: '智能识别表单指纹,自定义填充逻辑',
|
descriptionKey: 'features:textStatistics.description',
|
||||||
themeColor: THEME_COLORS.primary,
|
themeColorKey: 'secondary',
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
icon: FileText,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: FormMappingPage,
|
popup: TextStatisticsPage,
|
||||||
sidepanel: FormMappingPage,
|
sidepanel: TextStatisticsPage,
|
||||||
detached: FormMappingPage,
|
tab: TextStatisticsPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'formFill',
|
key: 'jwt',
|
||||||
label: '智能填充',
|
labelKey: 'features:jwt.title',
|
||||||
description: '根据表单指纹填充表单数据',
|
descriptionKey: 'features:jwt.description',
|
||||||
themeColor: THEME_COLORS.primary,
|
themeColorKey: 'info',
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
icon: Key,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: FormFillPage,
|
popup: JwtPage,
|
||||||
sidepanel: FormFillPage,
|
sidepanel: JwtPage,
|
||||||
detached: FormFillPage,
|
tab: JwtPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'formRecognizer',
|
key: 'jsonDiff',
|
||||||
label: '表单识别',
|
labelKey: 'features:jsonDiff.title',
|
||||||
description: '智能识别表单指纹',
|
descriptionKey: 'features:jsonDiff.description',
|
||||||
themeColor: THEME_COLORS.primary,
|
themeColorKey: 'primary',
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
icon: GitCompareArrows,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: FormRecognizerPage,
|
popup: JsonToolsPage,
|
||||||
sidepanel: FormRecognizerPage,
|
sidepanel: JsonToolsPage,
|
||||||
detached: FormRecognizerPage,
|
tab: JsonToolsPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'openUrlViewer',
|
key: 'base64Converter',
|
||||||
label: '查看',
|
labelKey: 'features:base64Converter.title',
|
||||||
description: '',
|
descriptionKey: 'features:base64Converter.description',
|
||||||
defaultVisible: false,
|
themeColorKey: 'info',
|
||||||
|
icon: ArrowLeftRight,
|
||||||
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
popup: OpenUrlViewerPage,
|
popup: Base64ConverterPage,
|
||||||
sidepanel: OpenUrlViewerPage,
|
sidepanel: Base64ConverterPage,
|
||||||
detached: OpenUrlViewerPage,
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -176,18 +200,16 @@ export function getAllFeatureKeys(): PageType[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultPageOrder(): PageType[] {
|
export function getDefaultPageOrder(): PageType[] {
|
||||||
return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map(
|
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
|
||||||
(f) => f.key,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' {
|
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
if (pathname.includes('sidepanel')) {
|
if (pathname.includes('sidepanel')) {
|
||||||
return 'sidepanel';
|
return 'sidepanel';
|
||||||
}
|
}
|
||||||
if (new URLSearchParams(window.location.search).get('mode') === 'detached') {
|
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
|
||||||
return 'detached';
|
return 'tab';
|
||||||
}
|
}
|
||||||
return 'popup';
|
return 'popup';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
import type { Theme } from '@mui/material';
|
|
||||||
import { alpha } from '@mui/material';
|
|
||||||
|
|
||||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
|
||||||
|
|
||||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
|
||||||
|
|
||||||
export type UnitType = 'ms' | 's';
|
|
||||||
export type ZoneType = (typeof ZONES)[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
|
|
||||||
* 所有颜色都经过对比度计算,确保可访问性
|
|
||||||
*/
|
|
||||||
export const THEME_COLORS = {
|
|
||||||
// 主要颜色 - 蓝色系
|
|
||||||
// 主色 #1976d2 在白底对比度 4.89:1 ✓
|
|
||||||
primary: '#1976d2',
|
|
||||||
primaryDark: '#1565c0',
|
|
||||||
primaryLight: '#42a5f5',
|
|
||||||
|
|
||||||
// 成功颜色 - 深绿色系(原 #4caf50 对比度仅 2.88:1,不达标)
|
|
||||||
// 新颜色 #2e7d32 在白底对比度 4.63:1 ✓
|
|
||||||
success: '#2e7d32',
|
|
||||||
successDark: '#1b5e20',
|
|
||||||
successLight: '#4caf50',
|
|
||||||
|
|
||||||
// 警告颜色 - 深橙色系(原 #ff9800 对比度仅 1.61:1,严重不达标)
|
|
||||||
// 新颜色 #e65100 在白底对比度 4.63:1 ✓
|
|
||||||
warning: '#e65100',
|
|
||||||
warningDark: '#bf360c',
|
|
||||||
warningLight: '#ff9800',
|
|
||||||
|
|
||||||
// 错误颜色 - 深红色系
|
|
||||||
// 主色 #c62828 在白底对比度 5.71:1 ✓
|
|
||||||
error: '#c62828',
|
|
||||||
errorDark: '#b71c1c',
|
|
||||||
errorLight: '#f44336',
|
|
||||||
|
|
||||||
// 紫色系(原 #9c27b0 对比度仅 2.23:1,不达标)
|
|
||||||
// 新颜色 #6a1b9a 在白底对比度 4.63:1 ✓
|
|
||||||
purple: '#6a1b9a',
|
|
||||||
purpleDark: '#4a148c',
|
|
||||||
purpleLight: '#9c27b0',
|
|
||||||
|
|
||||||
// 中性色
|
|
||||||
white: '#FFFFFF',
|
|
||||||
black: '#000000',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语义化的状态颜色别名
|
|
||||||
* 提供直观的状态表示,提高代码可读性
|
|
||||||
*/
|
|
||||||
export const STATUS_COLORS = {
|
|
||||||
success: THEME_COLORS.success,
|
|
||||||
warning: THEME_COLORS.warning,
|
|
||||||
error: THEME_COLORS.error,
|
|
||||||
info: THEME_COLORS.primary,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局样式配置
|
|
||||||
*/
|
|
||||||
export const globalStyles = {
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 时间戳转换页面样式
|
|
||||||
*/
|
|
||||||
export const timestampPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
INPUT_STYLE: {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'& fieldset': { border: 'none' },
|
|
||||||
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.primary.main}1a`,
|
|
||||||
},
|
|
||||||
'&.Mui-error': {
|
|
||||||
borderColor: 'error.main',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.error.main}1a`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': {
|
|
||||||
py: 1.4,
|
|
||||||
px: 2,
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cardBg: alpha(THEME_COLORS.primary, 0.04),
|
|
||||||
cardBorder: alpha(THEME_COLORS.primary, 0.1),
|
|
||||||
switcherBg: alpha(THEME_COLORS.primary, 0.08),
|
|
||||||
switcherBorder: alpha(THEME_COLORS.primary, 0.1),
|
|
||||||
mutedText: alpha(THEME_COLORS.primary, 0.4),
|
|
||||||
resultBg: alpha(THEME_COLORS.primary, 0.05),
|
|
||||||
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开 URL 页面样式
|
|
||||||
*/
|
|
||||||
export const openUrlPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.purple,
|
|
||||||
primaryDark: THEME_COLORS.purpleDark,
|
|
||||||
INPUT_STYLE: {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover fieldset': {
|
|
||||||
borderColor: 'grey.300',
|
|
||||||
},
|
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
|
||||||
'&.Mui-focused fieldset': {
|
|
||||||
borderColor: THEME_COLORS.purple,
|
|
||||||
},
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': {
|
|
||||||
py: '14px',
|
|
||||||
px: 2,
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
},
|
|
||||||
'& .MuiInputLabel-root': {
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&.Mui-focused': { color: THEME_COLORS.purple },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
themeColor: THEME_COLORS.purple,
|
|
||||||
themeBg: alpha(THEME_COLORS.purple, 0.1),
|
|
||||||
buttonBg: alpha(THEME_COLORS.purple, 0.85),
|
|
||||||
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.purple, 0.2)}`,
|
|
||||||
errorColor: THEME_COLORS.error,
|
|
||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查看 URL 页面样式
|
|
||||||
*/
|
|
||||||
export const openUrlViewerPageStyles = {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 存储清理页面样式
|
|
||||||
*/
|
|
||||||
export const storageCleanerPageStyles = {
|
|
||||||
warningColor: THEME_COLORS.warning,
|
|
||||||
warningDark: THEME_COLORS.warningDark,
|
|
||||||
warningBg: alpha(THEME_COLORS.warning, 0.05),
|
|
||||||
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
|
|
||||||
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
|
||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 二维码工具页面样式
|
|
||||||
* 注意:保留 successColor 和 successDark 以保持向后兼容性
|
|
||||||
*/
|
|
||||||
export const qrCodePageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.success,
|
|
||||||
primaryDark: THEME_COLORS.successDark,
|
|
||||||
successColor: THEME_COLORS.success,
|
|
||||||
successDark: THEME_COLORS.successDark,
|
|
||||||
white: THEME_COLORS.white,
|
|
||||||
black: THEME_COLORS.black,
|
|
||||||
INPUT_STYLE: {},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仪表盘页面样式
|
|
||||||
*/
|
|
||||||
export const dashboardPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
cardBackgroundColor: '#ffffff',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表单识别页面样式
|
|
||||||
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
|
|
||||||
*/
|
|
||||||
export const formRecognizerPageStyles = {
|
|
||||||
primaryColor: '#ff5722',
|
|
||||||
validColor: THEME_COLORS.success,
|
|
||||||
validDark: THEME_COLORS.successDark,
|
|
||||||
invalidColor: THEME_COLORS.warning,
|
|
||||||
invalidDark: THEME_COLORS.warningDark,
|
|
||||||
clearColor: THEME_COLORS.error,
|
|
||||||
clearDark: THEME_COLORS.errorDark,
|
|
||||||
clearBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
buttonStyle: {
|
|
||||||
py: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
fontWeight: 700,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表单映射页面样式
|
|
||||||
*/
|
|
||||||
export const formMappingPageStyles = {
|
|
||||||
secondaryColor: THEME_COLORS.purple,
|
|
||||||
} as const;
|
|
||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
|
||||||
import { THEME_COLORS } from './pageTheme';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一的 MUI 主题配置
|
|
||||||
* 整合了原有的外部 CSS 样式(滚动条、动画、基础重置)
|
|
||||||
*/
|
|
||||||
const theme = createTheme({
|
|
||||||
palette: {
|
|
||||||
primary: {
|
|
||||||
main: THEME_COLORS.primary,
|
|
||||||
dark: THEME_COLORS.primaryDark,
|
|
||||||
light: THEME_COLORS.primaryLight,
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
main: THEME_COLORS.success,
|
|
||||||
dark: THEME_COLORS.successDark,
|
|
||||||
light: THEME_COLORS.successLight,
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
main: THEME_COLORS.warning,
|
|
||||||
dark: THEME_COLORS.warningDark,
|
|
||||||
light: THEME_COLORS.warningLight,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: THEME_COLORS.error,
|
|
||||||
dark: THEME_COLORS.errorDark,
|
|
||||||
light: THEME_COLORS.errorLight,
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: THEME_COLORS.purple,
|
|
||||||
dark: THEME_COLORS.purpleDark,
|
|
||||||
light: THEME_COLORS.purpleLight,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
fontFamily: [
|
|
||||||
'-apple-system',
|
|
||||||
'BlinkMacSystemFont',
|
|
||||||
'"Segoe UI"',
|
|
||||||
'Roboto',
|
|
||||||
'"Helvetica Neue"',
|
|
||||||
'Arial',
|
|
||||||
'sans-serif',
|
|
||||||
'"Apple Color Emoji"',
|
|
||||||
'"Segoe UI Emoji"',
|
|
||||||
'"Segoe UI Symbol"',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiCssBaseline: {
|
|
||||||
styleOverrides: {
|
|
||||||
':root': {
|
|
||||||
'--sb-width': '6px',
|
|
||||||
'--sb-thumb-color': 'rgba(0, 0, 0, 0.1)',
|
|
||||||
'--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)',
|
|
||||||
'--sb-track-color': 'transparent',
|
|
||||||
},
|
|
||||||
'html, body, #root': {
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
minWidth: '400px',
|
|
||||||
minHeight: '600px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
// 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理,
|
|
||||||
// 这里提供全局基础,具体尺寸在 App 容器中限制)
|
|
||||||
body: {
|
|
||||||
WebkitFontSmoothing: 'antialiased',
|
|
||||||
MozOsxFontSmoothing: 'grayscale',
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
|
||||||
},
|
|
||||||
'h1, h2, h3, h4, h5, h6': {
|
|
||||||
fontSize: 'inherit',
|
|
||||||
fontWeight: 'inherit',
|
|
||||||
},
|
|
||||||
/* 全局极简滚动条定制 */
|
|
||||||
'*::-webkit-scrollbar': {
|
|
||||||
width: 'var(--sb-width)',
|
|
||||||
},
|
|
||||||
'*::-webkit-scrollbar-track': {
|
|
||||||
background: 'var(--sb-track-color)',
|
|
||||||
},
|
|
||||||
'*::-webkit-scrollbar-thumb': {
|
|
||||||
background: 'var(--sb-thumb-color)',
|
|
||||||
borderRadius: '10px',
|
|
||||||
backgroundClip: 'content-box',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
},
|
|
||||||
'*::-webkit-scrollbar-thumb:hover': {
|
|
||||||
background: 'var(--sb-thumb-hover)',
|
|
||||||
},
|
|
||||||
/* Animations */
|
|
||||||
'@keyframes slideInRight': {
|
|
||||||
from: {
|
|
||||||
transform: 'translateX(30px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'@keyframes fadeIn': {
|
|
||||||
from: {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.page-transition-enter': {
|
|
||||||
animation: 'slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards',
|
|
||||||
},
|
|
||||||
'.page-transition-dashboard': {
|
|
||||||
animation: 'fadeIn 0.3s ease-out forwards',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default theme;
|
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+165
-9
@@ -1,33 +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 } from '@/utils/messages';
|
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(() => {
|
||||||
|
createAllContextMenus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||||
|
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||||
|
|
||||||
|
if (isSidePanelOpen) {
|
||||||
|
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('[Context Menu] Side panel pipeline is not available:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveContextMenuData({ featureKey, payload });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.action.openPopup();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听扩展图标点击事件,安全激活侧边栏
|
||||||
browser.action.onClicked.addListener(async (tab) => {
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
if (tab.id) {
|
if (tab.id) {
|
||||||
try {
|
try {
|
||||||
await browser.sidePanel.open({ tabId: tab.id });
|
await browser.sidePanel.open({ tabId: tab.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to open side panel:', err);
|
console.error('Failed to open side panel via extension action clicked:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 @webext-core/messaging 处理消息
|
// 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
|
||||||
|
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
|
||||||
|
const sender = message.sender as chrome.runtime.MessageSender | undefined;
|
||||||
|
const tabId = sender?.tab?.id;
|
||||||
|
|
||||||
|
if (!tabId) {
|
||||||
|
console.warn('[RightClickRestorer] Injection request missing tabId');
|
||||||
|
return { success: false, message: 'Missing tabId' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: () => {
|
||||||
|
'use strict';
|
||||||
|
const w = window as unknown as Record<string, unknown>;
|
||||||
|
if (w.__testingToolsRightClickPatched) return;
|
||||||
|
w.__testingToolsRightClickPatched = true;
|
||||||
|
|
||||||
|
const PROTECTED = ['contextmenu', 'copy', 'paste', 'cut', 'selectstart'];
|
||||||
|
|
||||||
|
/* 1. 屏蔽 MouseEvent.prototype.preventDefault(含 mousedown 右键) */
|
||||||
|
const _origPreventDefault = MouseEvent.prototype.preventDefault;
|
||||||
|
Object.defineProperty(MouseEvent.prototype, 'preventDefault', {
|
||||||
|
value: function (this: MouseEvent) {
|
||||||
|
const t = this.type;
|
||||||
|
if (PROTECTED.includes(t) || (t === 'mousedown' && this.button === 2)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return _origPreventDefault.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 2. 屏蔽 Event.prototype.stopPropagation / stopImmediatePropagation */
|
||||||
|
const _origStopPropagation = Event.prototype.stopPropagation;
|
||||||
|
Object.defineProperty(Event.prototype, 'stopPropagation', {
|
||||||
|
value: function (this: Event) {
|
||||||
|
if (PROTECTED.includes(this.type)) return;
|
||||||
|
return _origStopPropagation.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _origStopImmediatePropagation = Event.prototype.stopImmediatePropagation;
|
||||||
|
Object.defineProperty(Event.prototype, 'stopImmediatePropagation', {
|
||||||
|
value: function (this: Event) {
|
||||||
|
if (PROTECTED.includes(this.type)) return;
|
||||||
|
return _origStopImmediatePropagation.call(this);
|
||||||
|
},
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 3. 拦截 document.oncontextmenu(处理 return false 方式) */
|
||||||
|
let _docOnContextMenu: unknown = null;
|
||||||
|
Object.defineProperty(document, 'oncontextmenu', {
|
||||||
|
get() {
|
||||||
|
return _docOnContextMenu;
|
||||||
|
},
|
||||||
|
set(fn: unknown) {
|
||||||
|
if (typeof fn === 'function') {
|
||||||
|
_docOnContextMenu = function (this: GlobalEventHandlers, e: MouseEvent) {
|
||||||
|
const r = (fn as (this: GlobalEventHandlers, ev: MouseEvent) => unknown).call(
|
||||||
|
this,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return r === false ? true : r;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
_docOnContextMenu = fn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
world: 'MAIN',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[RightClickRestorer] executeScript failed:', errorMsg);
|
||||||
|
return { success: false, message: errorMsg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 异步刷新请求监听(统一使用 alarms API,避免 MV3 Service Worker 被销毁导致任务丢失)
|
||||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||||
const { tabId, delay = 0 } = message.data;
|
const { tabId, delay = 0 } = message.data;
|
||||||
|
|
||||||
const executeReload = () => {
|
const executeReload = () => {
|
||||||
browser.tabs.reload(tabId);
|
browser.tabs.reload(tabId).catch((err) => {
|
||||||
|
console.error('Failed to execute tab reload operation:', err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (delay > 0) {
|
if (delay <= 0) {
|
||||||
setTimeout(executeReload, delay);
|
|
||||||
} else {
|
|
||||||
executeReload();
|
executeReload();
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, message: '刷新请求已接收' };
|
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,15 +1,10 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
import '../.wxt/types/imports.d.ts';
|
||||||
import { initFormMappingHelper } from '@/utils/formMapping/ui';
|
|
||||||
import { initMessageHandler } from './content/messageHandler';
|
import { initMessageHandler } from './content/messageHandler';
|
||||||
|
|
||||||
export default defineContentScript({
|
export default defineContentScript({
|
||||||
matches: ['<all_urls>'],
|
matches: ['<all_urls>'],
|
||||||
runAt: 'document_end',
|
runAt: 'document_end',
|
||||||
main() {
|
main() {
|
||||||
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
|
|
||||||
initFormMappingHelper();
|
|
||||||
|
|
||||||
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
|
|
||||||
initMessageHandler();
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,160 +1,5 @@
|
|||||||
import {
|
import { initContextMenuHandler } from './contextMenuHandler';
|
||||||
fillAllFields,
|
|
||||||
clearAllFields,
|
|
||||||
fillSelectedFields,
|
|
||||||
scanFormFields,
|
|
||||||
highlightField,
|
|
||||||
unhighlightField,
|
|
||||||
flashField,
|
|
||||||
FillMode,
|
|
||||||
type FormFieldInfo,
|
|
||||||
} from '@/utils/dummyDataGenerator';
|
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
|
||||||
import {
|
|
||||||
FuzzyMatcher,
|
|
||||||
SmartInjectionEngine,
|
|
||||||
FeedbackRenderer,
|
|
||||||
} from '@/utils/formMapping/smartInjector';
|
|
||||||
import { FormMapEntry } from '@/types/storage';
|
|
||||||
|
|
||||||
// 存储当前扫描到的字段列表,用于高亮联动
|
export function initMessageHandler(): void {
|
||||||
let currentFields: FormFieldInfo[] = [];
|
initContextMenuHandler();
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化消息处理器
|
|
||||||
*/
|
|
||||||
export function initMessageHandler() {
|
|
||||||
onMessage(MessageAction.SCAN_FORM_FIELDS, async () => {
|
|
||||||
const result = scanFormFields();
|
|
||||||
currentFields = result.fields;
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
fields: result.fields.map((f) => ({
|
|
||||||
id: f.id,
|
|
||||||
fieldType: f.fieldType,
|
|
||||||
label: f.label,
|
|
||||||
placeholder: f.placeholder,
|
|
||||||
name: f.name,
|
|
||||||
value: f.value,
|
|
||||||
isSelected: f.isSelected,
|
|
||||||
generatedValue: f.generatedValue,
|
|
||||||
})),
|
|
||||||
totalCount: result.totalCount,
|
|
||||||
validCount: result.validCount,
|
|
||||||
hasModal: !!result.modalContainer,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FILL_VALID_DATA, async (message) => {
|
|
||||||
fillAllFields(FillMode.VALID, message.data.includeHidden || false);
|
|
||||||
return { success: true, message: '已填充有效数据' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FILL_INVALID_DATA, async (message) => {
|
|
||||||
fillAllFields(FillMode.INVALID, message.data.includeHidden || false);
|
|
||||||
return { success: true, message: '已填充无效数据' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FILL_SELECTED_FIELDS, async (message) => {
|
|
||||||
const { fields: incomingFields, mode } = message.data;
|
|
||||||
const fieldsToFill = currentFields.map((field) => {
|
|
||||||
const incomingField = incomingFields.find((f) => f.id === field.id);
|
|
||||||
if (incomingField) {
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
fieldType: incomingField.fieldType,
|
|
||||||
isSelected: incomingField.isSelected,
|
|
||||||
useInvalidData: incomingField.useInvalidData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
});
|
|
||||||
const count = fillSelectedFields(fieldsToFill, mode || FillMode.VALID);
|
|
||||||
return { success: true, message: `已填充 ${count} 个字段` };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.CLEAR_ALL_FIELDS, async () => {
|
|
||||||
clearAllFields();
|
|
||||||
return { success: true, message: '已清空所有字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.HIGHLIGHT_FIELD, async (message) => {
|
|
||||||
const { fieldId } = message.data;
|
|
||||||
const field = currentFields.find((f) => f.id === fieldId);
|
|
||||||
if (field) {
|
|
||||||
highlightField(field.element);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, message: '未找到字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.UNHIGHLIGHT_FIELD, async (message) => {
|
|
||||||
const { fieldId } = message.data;
|
|
||||||
const field = currentFields.find((f) => f.id === fieldId);
|
|
||||||
if (field) {
|
|
||||||
unhighlightField(field.element);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, message: '未找到字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.HIGHLIGHT_ALL_FIELDS, async (message) => {
|
|
||||||
const { fieldIds } = message.data;
|
|
||||||
fieldIds.forEach((id) => {
|
|
||||||
const field = currentFields.find((f) => f.id === id);
|
|
||||||
if (field) {
|
|
||||||
highlightField(field.element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.UNHIGHLIGHT_ALL_FIELDS, async () => {
|
|
||||||
currentFields.forEach((field) => {
|
|
||||||
unhighlightField(field.element);
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FLASH_FIELD, async (message) => {
|
|
||||||
const { fieldId } = message.data;
|
|
||||||
const field = currentFields.find((f) => f.id === fieldId);
|
|
||||||
if (field) {
|
|
||||||
flashField(field.element);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, message: '未找到字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FORM_INJECT, async (message) => {
|
|
||||||
try {
|
|
||||||
const injectData =
|
|
||||||
(message.data.data as Array<{ entry: FormMapEntry; mockValue: string }>) || [];
|
|
||||||
const results = injectData.map((item) => {
|
|
||||||
const matchResult = FuzzyMatcher.findTargetElement(item.entry.fingerprint);
|
|
||||||
if (matchResult.element) {
|
|
||||||
const injectResult = SmartInjectionEngine.inject(
|
|
||||||
matchResult.element,
|
|
||||||
item.entry,
|
|
||||||
item.mockValue,
|
|
||||||
);
|
|
||||||
if (injectResult.success) {
|
|
||||||
FeedbackRenderer.renderSuccess(matchResult.element);
|
|
||||||
} else {
|
|
||||||
FeedbackRenderer.renderError(matchResult.element);
|
|
||||||
}
|
|
||||||
return { id: item.entry.id, success: injectResult.success };
|
|
||||||
} else {
|
|
||||||
return { id: item.entry.id, success: false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { success: true, results };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('智能注入失败:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : '注入失败',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '📊 文本统计');
|
||||||
|
}
|
||||||
+304
-148
@@ -1,52 +1,235 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { GripVertical, RefreshCw } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Box,
|
closestCenter,
|
||||||
Typography,
|
DndContext,
|
||||||
Paper,
|
type DragEndEvent,
|
||||||
Switch,
|
KeyboardSensor,
|
||||||
Button,
|
PointerSensor,
|
||||||
CircularProgress,
|
useSensor,
|
||||||
Stack,
|
useSensors,
|
||||||
IconButton,
|
} from '@dnd-kit/core';
|
||||||
} from '@mui/material';
|
import {
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
arrayMove,
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
SortableContext,
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
sortableKeyboardCoordinates,
|
||||||
import type { PageType } from '@/types/storage';
|
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 { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import type { PaletteColorKey } from '@/config/features';
|
||||||
import {
|
import {
|
||||||
getFeatureByKey,
|
getAllFeatureKeys,
|
||||||
getDefaultPageOrder,
|
getDefaultPageOrder,
|
||||||
getDefaultVisibleFeatureKeys,
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getFeatureByKey,
|
||||||
} from '@/config/features';
|
} from '@/config/features';
|
||||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
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() {
|
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 [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
||||||
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const { snackbarProps, showMessage } = useSnackbarState();
|
const { snackbarProps, showMessage } = useSnackbarState();
|
||||||
|
|
||||||
useEffect(() => {
|
const sensors = useSensors(
|
||||||
loadConfig().catch(console.error);
|
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||||
}, []);
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
);
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const configKeys = useMemo(() => {
|
||||||
try {
|
switch (windowType) {
|
||||||
const [savedVisible, savedOrder] = await Promise.all([
|
case 'sidepanel':
|
||||||
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
|
return {
|
||||||
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
|
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
|
||||||
]);
|
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
|
||||||
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
|
};
|
||||||
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
|
case 'tab':
|
||||||
} catch (error) {
|
return {
|
||||||
console.error('Failed to load config:', error);
|
visible: 'app/tabVisiblePages' as keyof StorageSchema,
|
||||||
setVisiblePages(getDefaultVisibleFeatureKeys());
|
order: 'app/tabPageOrder' as keyof StorageSchema,
|
||||||
setPageOrder(getDefaultPageOrder());
|
};
|
||||||
} finally {
|
case 'popup':
|
||||||
setIsLoaded(true);
|
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 handlePageToggle = async (page: PageType) => {
|
||||||
@@ -64,27 +247,30 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storageUtil.set('app/visiblePages', newPages);
|
await storageUtil.set(configKeys.visible, newPages);
|
||||||
setVisiblePages(newPages);
|
setVisiblePages(newPages);
|
||||||
const feature = getFeatureByKey(page);
|
const feature = getFeatureByKey(page);
|
||||||
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
|
const label = feature ? t(feature.labelKey) : page;
|
||||||
|
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save config:', error);
|
console.error('Failed to save config:', error);
|
||||||
showToast('保存失败', 'warning');
|
showToast('保存失败', 'warning');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMove = async (index: number, direction: 'up' | 'down') => {
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
if (direction === 'up' && index === 0) return;
|
const { active, over } = event;
|
||||||
if (direction === 'down' && index === pageOrder.length - 1) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const newOrder = [...pageOrder];
|
const oldIndex = pageOrder.indexOf(active.id as PageType);
|
||||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
const newIndex = pageOrder.indexOf(over.id as PageType);
|
||||||
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
|
if (oldIndex < 0 || newIndex < 0) return;
|
||||||
|
|
||||||
|
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
|
||||||
|
setPageOrder(newOrder);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await storageUtil.set('app/pageOrder', newOrder);
|
await storageUtil.set(configKeys.order, newOrder);
|
||||||
setPageOrder(newOrder);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save order:', error);
|
console.error('Failed to save order:', error);
|
||||||
showToast('排序保存失败', 'warning');
|
showToast('排序保存失败', 'warning');
|
||||||
@@ -93,135 +279,105 @@ export default function App() {
|
|||||||
|
|
||||||
const handleRestoreDefaults = async () => {
|
const handleRestoreDefaults = async () => {
|
||||||
try {
|
try {
|
||||||
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
|
|
||||||
const defaults = getDefaultVisibleFeatureKeys();
|
const defaults = getDefaultVisibleFeatureKeys();
|
||||||
const defaultOrder = getDefaultPageOrder();
|
const defaultOrder = getDefaultPageOrder();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
storageUtil.set('app/visiblePages', defaults),
|
storageUtil.set(configKeys.visible, defaults),
|
||||||
storageUtil.set('app/pageOrder', defaultOrder),
|
storageUtil.set(configKeys.order, defaultOrder),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setVisiblePages(defaults);
|
setVisiblePages(defaults);
|
||||||
setPageOrder(defaultOrder);
|
setPageOrder(defaultOrder);
|
||||||
showToast('已恢复默认', 'success');
|
showToast('已恢复当前模式默认设置', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to restore defaults:', error);
|
console.error('Failed to restore defaults:', error);
|
||||||
showToast('恢复失败', 'warning');
|
showToast('恢复失败', 'warning');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
|
||||||
showMessage(message, { severity });
|
if (newType !== null) {
|
||||||
|
setWindowType(newType);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<div className="flex justify-center items-center min-h-screen">
|
||||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
>
|
</div>
|
||||||
<CircularProgress size={24} />
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<div className="app min-h-screen bg-background flex flex-col">
|
||||||
className="app"
|
<PageErrorBoundary>
|
||||||
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
|
{/* 顶部标题与导航栏 */}
|
||||||
>
|
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
|
||||||
<ErrorBoundary>
|
<div className="max-w-3xl mx-auto">
|
||||||
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
|
{/* Tab 与恢复按钮同行 */}
|
||||||
<Stack
|
<div className="flex items-end justify-between border-b-0">
|
||||||
direction="row"
|
<div className="flex-1 flex">
|
||||||
justifyContent="space-between"
|
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
|
||||||
alignItems="flex-start"
|
<button
|
||||||
sx={{ mb: 4 }}
|
key={type}
|
||||||
>
|
onClick={(e) => handleWindowTypeChange(e, type)}
|
||||||
<Button
|
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
|
||||||
variant="text"
|
windowType === type
|
||||||
size="small"
|
? 'text-primary border-b-2 border-primary'
|
||||||
onClick={handleRestoreDefaults}
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
|
}`}
|
||||||
sx={{ color: 'text.secondary', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
恢复默认
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
overflow: 'hidden',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{pageOrder.map((key, index, array) => {
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (!feature) return null;
|
|
||||||
|
|
||||||
const isChecked = visiblePages.includes(key);
|
|
||||||
const isDisabled = isChecked && visiblePages.length === 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={key}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 2.5,
|
|
||||||
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Box>
|
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
</button>
|
||||||
{feature.label}
|
))}
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<button
|
||||||
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
|
onClick={handleRestoreDefaults}
|
||||||
</Typography>
|
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
|
||||||
</Box>
|
title="恢复当前模式默认"
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
>
|
||||||
<IconButton
|
<RefreshCw size={16} />
|
||||||
size="small"
|
</button>
|
||||||
onClick={() => handleMove(index, 'up')}
|
</div>
|
||||||
disabled={index === 0}
|
</div>
|
||||||
sx={{ color: 'text.secondary' }}
|
</div>
|
||||||
>
|
|
||||||
<KeyboardArrowUpIcon fontSize="small" />
|
{/* 主内容区域 */}
|
||||||
</IconButton>
|
<div className="flex-1 p-4 sm:p-8">
|
||||||
<IconButton
|
<div className="max-w-3xl mx-auto">
|
||||||
size="small"
|
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
|
||||||
onClick={() => handleMove(index, 'down')}
|
<DndContext
|
||||||
disabled={index === array.length - 1}
|
sensors={sensors}
|
||||||
sx={{ color: 'text.secondary' }}
|
collisionDetection={closestCenter}
|
||||||
>
|
onDragEnd={handleDragEnd}
|
||||||
<KeyboardArrowDownIcon fontSize="small" />
|
>
|
||||||
</IconButton>
|
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
|
||||||
<Switch
|
<div className="flex flex-col">
|
||||||
size="small"
|
{pageOrder.map((key, index, array) => {
|
||||||
checked={isChecked}
|
const isChecked = visiblePages.includes(key);
|
||||||
onChange={() => handlePageToggle(key)}
|
const isDisabled = isChecked && visiblePages.length === 1;
|
||||||
disabled={isDisabled}
|
return (
|
||||||
/>
|
<SortableFeatureRow
|
||||||
</Box>
|
key={key}
|
||||||
</Box>
|
pageKey={key}
|
||||||
);
|
isLast={index === array.length - 1}
|
||||||
})}
|
isChecked={isChecked}
|
||||||
</Box>
|
isDisabled={isDisabled}
|
||||||
</Paper>
|
onToggle={handlePageToggle}
|
||||||
</Box>
|
/>
|
||||||
</ErrorBoundary>
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageErrorBoundary>
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
<GlobalSnackbar {...snackbarProps} />
|
||||||
</Box>
|
</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,15 +1,11 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import AppRoot from '@/providers/AppRoot';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import '@/i18n';
|
||||||
import theme from '@/config/theme';
|
import '@/src/index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<ThemeProvider theme={theme}>
|
<App />
|
||||||
<CssBaseline />
|
</AppRoot>,
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
+28
-24
@@ -2,41 +2,45 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { globalStyles } from '@/config/pageTheme';
|
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
import { Box } from '@mui/material';
|
import { getEntryPointType } from '@/config/features';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
|
|
||||||
const handleOpenOptions = () => {
|
const handleOpenOptions = () => {
|
||||||
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
|
chrome.runtime.openOptionsPage().catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const entryType = useMemo(() => getEntryPointType(), []);
|
||||||
|
|
||||||
|
const routerConfig = useMemo(() => {
|
||||||
|
if (entryType === 'tab') {
|
||||||
|
return {
|
||||||
|
syncKey: 'app/tabRoute' as const,
|
||||||
|
visiblePagesKey: 'app/tabVisiblePages' as const,
|
||||||
|
pageOrderKey: 'app/tabPageOrder' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
syncKey: 'app/popupRoute' as const,
|
||||||
|
visiblePagesKey: 'app/popupVisiblePages' as const,
|
||||||
|
pageOrderKey: 'app/popupPageOrder' as const,
|
||||||
|
};
|
||||||
|
}, [entryType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider syncKey="app/popupRoute">
|
<RouterProvider
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
|
syncKey={routerConfig.syncKey}
|
||||||
<Box
|
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||||
className="app"
|
pageOrderKey={routerConfig.pageOrderKey}
|
||||||
sx={{
|
>
|
||||||
display: 'flex',
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
flexDirection: 'column',
|
<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">
|
||||||
width: '400px',
|
|
||||||
height: '600px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: globalStyles.backgroundColor,
|
|
||||||
'@media screen and (min-width: 401px), screen and (min-height: 601px)': {
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
minWidth: '400px',
|
|
||||||
minHeight: '600px',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TopBar onOpenOptions={handleOpenOptions} />
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<RouterContainer />
|
<RouterContainer />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</Box>
|
</div>
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>我是独立窗口</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,15 +1,11 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import AppRoot from '@/providers/AppRoot';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import '@/i18n';
|
||||||
import theme from '@/config/theme';
|
import '@/src/index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<ThemeProvider theme={theme}>
|
<App />
|
||||||
<CssBaseline />
|
</AppRoot>,
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
|
||||||
import DashboardCard from '@/components/DashboardCard';
|
|
||||||
import { getFeatureByKey } from '@/config/features';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { dashboardPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
|
||||||
|
|
||||||
const isVisible = (key: string) => visiblePages.includes(key as PageType);
|
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
|
||||||
(page: PageType) => {
|
|
||||||
navigateTo(page);
|
|
||||||
},
|
|
||||||
[navigateTo],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
|
|
||||||
{pageOrder.map((key) => {
|
|
||||||
if (!isVisible(key)) return null;
|
|
||||||
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (!feature || !feature.icon || !feature.themeColor) return null;
|
|
||||||
|
|
||||||
// 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
|
|
||||||
const cardConfig = {
|
|
||||||
title: feature.label,
|
|
||||||
description: feature.description,
|
|
||||||
colorCode: feature.themeColor,
|
|
||||||
icon: feature.icon,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
key={key}
|
|
||||||
config={cardConfig}
|
|
||||||
onClick={() => handleCardClick(key)}
|
|
||||||
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Container,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
|
||||||
Switch,
|
|
||||||
Divider,
|
|
||||||
Paper,
|
|
||||||
Chip,
|
|
||||||
} from '@mui/material';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import { FormMapEntry } from '@/types/storage';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { formMappingPageStyles } from '@/config/pageTheme.ts';
|
|
||||||
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
|
|
||||||
import { Button } from '@/components/Button';
|
|
||||||
import { FormInjectResult, MessageAction, sendMessage } from '@/utils/messages';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
|
|
||||||
export default function FormFillPage() {
|
|
||||||
const [entries, setEntries] = useState<FormMapEntry[]>([]);
|
|
||||||
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
|
|
||||||
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
|
|
||||||
const [isInjecting, setIsInjecting] = useState(false);
|
|
||||||
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
|
|
||||||
|
|
||||||
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
|
|
||||||
const preview = new Map<string, string>();
|
|
||||||
items.forEach((entry) => {
|
|
||||||
const value = MockDataGenerator.generate(entry.action_logic, entry);
|
|
||||||
preview.set(entry.id, value);
|
|
||||||
});
|
|
||||||
setPreviewData(preview);
|
|
||||||
setInjectResults(new Map());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadEntries = async () => {
|
|
||||||
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
|
|
||||||
setEntries(data || []);
|
|
||||||
generatePreviewData(data || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadEntries().catch((r) => console.error(r));
|
|
||||||
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
|
|
||||||
if (area === 'local' && changes['active_form_map']) {
|
|
||||||
loadEntries().catch((r) => console.error(r));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
chrome.storage.onChanged.addListener(listener);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(listener);
|
|
||||||
}, [generatePreviewData]);
|
|
||||||
|
|
||||||
const refreshPreview = () => {
|
|
||||||
generatePreviewData(entries);
|
|
||||||
};
|
|
||||||
|
|
||||||
const injectAllFields = async () => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
showMessage('没有可填充的字段', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsInjecting(true);
|
|
||||||
const results = new Map<string, boolean>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 发送消息到 content script 执行注入
|
|
||||||
const response = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
if (response.length === 0) {
|
|
||||||
throw new Error('无法获取当前标签页');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabId = response[0].id;
|
|
||||||
if (!tabId) {
|
|
||||||
throw new Error('标签页ID无效');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 准备注入数据
|
|
||||||
const injectData = entries.map((entry) => ({
|
|
||||||
entry,
|
|
||||||
mockValue: previewData.get(entry.id) || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 执行注入 (使用 type-safe sendMessage)
|
|
||||||
const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId);
|
|
||||||
|
|
||||||
if (result && result.success) {
|
|
||||||
result.results?.forEach((r: FormInjectResult) => {
|
|
||||||
results.set(r.id, r.success);
|
|
||||||
});
|
|
||||||
setInjectResults(results);
|
|
||||||
showMessage('填充成功!', { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
throw new Error(result?.error || result?.message || '注入失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注入失败:', error);
|
|
||||||
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单');
|
|
||||||
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', {
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsInjecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFieldTypeLabel = (type: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
text: '文本',
|
|
||||||
select: '下拉框',
|
|
||||||
checkbox: '复选框',
|
|
||||||
radio: '单选框',
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStrategyLabel = (strategy: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
fixed: '固定值',
|
|
||||||
random: '随机',
|
|
||||||
sequence: '序列',
|
|
||||||
};
|
|
||||||
return labels[strategy] || strategy;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<PageHeader
|
|
||||||
title="智能表单填充"
|
|
||||||
subtitle="基于指纹识别的精准数据注入"
|
|
||||||
icon={<PlayArrowIcon />}
|
|
||||||
/>
|
|
||||||
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
|
|
||||||
{/* 操作区域 */}
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
mb: 2.5,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
|
|
||||||
填充控制
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={refreshPreview}
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
>
|
|
||||||
刷新预览
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={injectAllFields}
|
|
||||||
size="small"
|
|
||||||
startIcon={<PlayArrowIcon />}
|
|
||||||
disabled={isInjecting || entries.length === 0}
|
|
||||||
sx={{
|
|
||||||
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isInjecting ? '注入中...' : '开始填充'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
点击"开始填充"后,将根据映射配置向网页表单注入数据。
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* 字段列表 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
px: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
|
|
||||||
映射字段 ({entries.length})
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<List
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary="暂无映射字段"
|
|
||||||
secondary="请先在表单映射页面配置字段"
|
|
||||||
slotProps={{
|
|
||||||
primary: {
|
|
||||||
align: 'center',
|
|
||||||
color: 'text.secondary',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
entries.map((entry, index) => (
|
|
||||||
<Box key={entry.id}>
|
|
||||||
{index > 0 && <Divider />}
|
|
||||||
<ListItem
|
|
||||||
secondaryAction={
|
|
||||||
<IconButton edge="end" aria-label="preview">
|
|
||||||
<VisibilityIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
sx={{ py: 1.5 }}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
edge="start"
|
|
||||||
checked={entry.ui_state.is_selected}
|
|
||||||
disabled
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
/>
|
|
||||||
<ListItemText
|
|
||||||
slotProps={{
|
|
||||||
primary: {
|
|
||||||
component: 'div',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
component: 'div',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={getFieldTypeLabel(entry.action_logic.type)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
color: 'grey.700',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={getStrategyLabel(entry.action_logic.strategy)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
bgcolor: formMappingPageStyles.secondaryColor + '20',
|
|
||||||
color: formMappingPageStyles.secondaryColor || '#9c27b0',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
color: 'text.secondary',
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.fingerprint.selector}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'primary.main',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
maxWidth: '250px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
预览: {previewData.get(entry.id) || '---'}
|
|
||||||
</Typography>
|
|
||||||
{injectResults.has(entry.id) &&
|
|
||||||
(injectResults.get(entry.id) ? (
|
|
||||||
<CheckCircleIcon sx={{ color: '#32CD32', fontSize: '1rem' }} />
|
|
||||||
) : (
|
|
||||||
<CancelIcon sx={{ color: '#FF4444', fontSize: '1rem' }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{/* 统计信息 */}
|
|
||||||
{injectResults.size > 0 && (
|
|
||||||
<Box sx={{ mt: 4 }}>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-around' }}>
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Typography variant="h5" fontWeight={800} color="primary.main">
|
|
||||||
{Array.from(injectResults.values()).filter(Boolean).length}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
成功注入
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Typography variant="h5" fontWeight={800} color="error.main">
|
|
||||||
{Array.from(injectResults.values()).filter((v) => !v).length}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
注入失败
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Container,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
|
||||||
Switch,
|
|
||||||
Divider,
|
|
||||||
Paper,
|
|
||||||
} from '@mui/material';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
|
||||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
|
||||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import { FormMapEntry } from '@/types/storage';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
|
|
||||||
export default function FormMappingPage() {
|
|
||||||
const [entries, setEntries] = useState<FormMapEntry[]>([]);
|
|
||||||
const [isPicking, setIsPicking] = useState(false);
|
|
||||||
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadData = async () => {
|
|
||||||
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
|
|
||||||
setEntries(data || []);
|
|
||||||
const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean;
|
|
||||||
setIsPicking(picking || false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData().catch((r) => console.error(r));
|
|
||||||
|
|
||||||
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
|
|
||||||
if (area === 'local') {
|
|
||||||
if (changes['active_form_map']) {
|
|
||||||
setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []);
|
|
||||||
}
|
|
||||||
if (changes['app/formMapping/isPicking']) {
|
|
||||||
setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(listener);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(listener);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePicking = async () => {
|
|
||||||
await storageUtil.set('app/formMapping/isPicking', !isPicking);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEntry = async (id: string) => {
|
|
||||||
const newEntries = entries.filter((e) => e.id !== id);
|
|
||||||
await storageUtil.set('active_form_map', newEntries);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelection = async (id: string) => {
|
|
||||||
const newEntries = entries.map((e) =>
|
|
||||||
e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e,
|
|
||||||
);
|
|
||||||
await storageUtil.set('active_form_map', newEntries);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = async () => {
|
|
||||||
await storageUtil.set('active_form_map', []);
|
|
||||||
await storageUtil.set('app/formMapping/isPicking', false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportConfig = () => {
|
|
||||||
try {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
showMessage('没有可导出的配置数据', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonStr = JSON.stringify(entries, null, 2);
|
|
||||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const date = new Date();
|
|
||||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
const filename = `form-mapping-config-${dateStr}.json`;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
showMessage('配置导出成功!', { severity: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('导出配置失败:', error);
|
|
||||||
showMessage(error instanceof Error ? error.message : '导出失败,请重试', {
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<PageHeader
|
|
||||||
title="通用表单映射助手"
|
|
||||||
subtitle="智能识别表单指纹,自定义填充逻辑"
|
|
||||||
icon={<AutoFixHighIcon />}
|
|
||||||
/>
|
|
||||||
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
mb: 2.5,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
|
|
||||||
状态控制
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant={isPicking ? 'contained' : 'outlined'}
|
|
||||||
onClick={togglePicking}
|
|
||||||
size="small"
|
|
||||||
startIcon={<AddCircleOutlineIcon />}
|
|
||||||
>
|
|
||||||
{isPicking ? '正在拾取...' : '开始拾取'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
点击“开始拾取”后,直接在网页上点击想要映射的表单元素。
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
px: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
|
|
||||||
已拾取字段 ({entries.length})
|
|
||||||
</Typography>
|
|
||||||
<Button size="small" color="error" onClick={clearAll}>
|
|
||||||
清空全部
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<List
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary="暂无数据"
|
|
||||||
secondary="点击上方按钮开始探测网页表单"
|
|
||||||
slotProps={{
|
|
||||||
primary: {
|
|
||||||
align: 'center',
|
|
||||||
color: 'text.secondary',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
entries.map((entry, index) => (
|
|
||||||
<Box key={entry.id}>
|
|
||||||
{index > 0 && <Divider />}
|
|
||||||
<ListItem
|
|
||||||
secondaryAction={
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
aria-label="delete"
|
|
||||||
onClick={() => deleteEntry(entry.id)}
|
|
||||||
sx={{ color: 'error.light' }}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
sx={{ py: 1.5 }}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
edge="start"
|
|
||||||
checked={entry.ui_state.is_selected}
|
|
||||||
onChange={() => toggleSelection(entry.id)}
|
|
||||||
/>
|
|
||||||
<ListItemText
|
|
||||||
primary={entry.label_display}
|
|
||||||
secondary={entry.fingerprint.selector}
|
|
||||||
slotProps={{
|
|
||||||
primary: { fontWeight: 500 },
|
|
||||||
secondary: {
|
|
||||||
sx: {
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: '200px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<Box sx={{ mt: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
px: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.secondary' }}>
|
|
||||||
映射配置导出 (JSON)
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={exportConfig}
|
|
||||||
startIcon={<FileDownloadIcon />}
|
|
||||||
>
|
|
||||||
导出配置
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
maxHeight: '180px',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<pre style={{ margin: 0 }}>{JSON.stringify(entries, null, 2)}</pre>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import InputIcon from '@mui/icons-material/Input';
|
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
||||||
import { formRecognizerPageStyles } from '@/config/pageTheme';
|
|
||||||
import FieldList from '@/components/FieldList';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { useFormRecognizer } from './hooks/useFormRecognizer';
|
|
||||||
|
|
||||||
export default function FormRecognizerPage() {
|
|
||||||
const {
|
|
||||||
fillLoading,
|
|
||||||
clearLoading,
|
|
||||||
includeHidden,
|
|
||||||
setIncludeHidden,
|
|
||||||
fields,
|
|
||||||
scanning,
|
|
||||||
showFields,
|
|
||||||
setShowFields,
|
|
||||||
hoveredFieldId,
|
|
||||||
sidePanelOpen,
|
|
||||||
handleScanFields,
|
|
||||||
handleFieldTypeChange,
|
|
||||||
handleToggleFieldSelection,
|
|
||||||
handleToggleAllFields,
|
|
||||||
handleLocateField,
|
|
||||||
handleHoverField,
|
|
||||||
handleFillSelectedFields,
|
|
||||||
handleClearAllFields,
|
|
||||||
handleOpenSidePanel,
|
|
||||||
selectedCount,
|
|
||||||
} = useFormRecognizer();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="表单测试数据填充器"
|
|
||||||
subtitle="一键填充表单测试数据,提升开发和测试效率"
|
|
||||||
icon={<InputIcon />}
|
|
||||||
iconColor={formRecognizerPageStyles.primaryColor}
|
|
||||||
sx={{ mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<OpenInNewIcon />}
|
|
||||||
onClick={handleOpenSidePanel}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
opacity: sidePanelOpen ? 0 : 1,
|
|
||||||
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
|
|
||||||
pointerEvents: sidePanelOpen ? 'none' : 'auto',
|
|
||||||
visibility: sidePanelOpen ? 'hidden' : 'visible',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
侧边栏
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 扫描按钮 */}
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleScanFields}
|
|
||||||
disabled={scanning}
|
|
||||||
fullWidth
|
|
||||||
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
|
|
||||||
>
|
|
||||||
{scanning ? '扫描中...' : '扫描表单字段'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<FieldList
|
|
||||||
fields={fields}
|
|
||||||
showFields={showFields}
|
|
||||||
onToggleShowFields={() => setShowFields(!showFields)}
|
|
||||||
onFieldTypeChange={handleFieldTypeChange}
|
|
||||||
onLocateField={handleLocateField}
|
|
||||||
onHoverField={handleHoverField}
|
|
||||||
onToggleFieldSelection={handleToggleFieldSelection}
|
|
||||||
onToggleAllFields={handleToggleAllFields}
|
|
||||||
hoveredFieldId={hoveredFieldId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Button
|
|
||||||
disableElevation
|
|
||||||
disableRipple
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleFillSelectedFields}
|
|
||||||
disabled={fillLoading || selectedCount === 0}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
填充选中字段
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
disableElevation
|
|
||||||
disableRipple
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleClearAllFields}
|
|
||||||
disabled={clearLoading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
清空所有字段
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={includeHidden}
|
|
||||||
onChange={(e) => setIncludeHidden(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="包含隐藏字段"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Box, Typography, Container } from '@mui/material';
|
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import UrlEntryForm from '@/components/UrlEntryForm';
|
|
||||||
import UrlEntryList from '@/components/UrlEntryList';
|
|
||||||
import { useUrlPreferences } from '@/utils/useUrlPreferences';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
|
|
||||||
export default function OpenUrlPage() {
|
|
||||||
const { entries, setEntries, isLoaded } = useUrlPreferences();
|
|
||||||
const { showMessage } = useGlobalSnackbar();
|
|
||||||
|
|
||||||
const handleAddEntry = (entry: OpenUrlEntry) => {
|
|
||||||
setEntries([...entries, entry]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteEntry = (index: number) => {
|
|
||||||
const newEntries = [...entries];
|
|
||||||
newEntries.splice(index, 1);
|
|
||||||
setEntries(newEntries);
|
|
||||||
showMessage('删除成功', { severity: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ minHeight: '100%', pb: 3 }}>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<Typography>加载中...</Typography>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="URL 工具"
|
|
||||||
subtitle="快速打开 URL 或复制链接"
|
|
||||||
icon={<LanguageIcon />}
|
|
||||||
iconColor={openUrlPageStyles.primaryColor}
|
|
||||||
sx={{ mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Form Section */}
|
|
||||||
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
|
|
||||||
|
|
||||||
{/* List Section */}
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.secondary', fontWeight: 800, px: 1, mb: 1, display: 'block' }}
|
|
||||||
>
|
|
||||||
已保存的快捷方式 ({entries.length})
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<UrlEntryList
|
|
||||||
entries={entries}
|
|
||||||
onDeleteEntry={handleDeleteEntry}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Box, Typography, CircularProgress, Alert } from '@mui/material';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
|
|
||||||
// 只允许 HTTP/HTTPS 协议,阻止危险协议
|
|
||||||
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
|
|
||||||
|
|
||||||
export default function OpenUrlViewerPage() {
|
|
||||||
const [currentUrl, setCurrentUrl] = useState<string>('');
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const [iframeLoading, setIframeLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 验证 URL 是否安全
|
|
||||||
const validateUrl = (url: string): string | null => {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) {
|
|
||||||
return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return '无效的 URL 格式';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从存储加载当前选中的 URL
|
|
||||||
const loadCurrentUrl = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const saved = await storageUtil.get('openUrl/currentUrl', '');
|
|
||||||
if (saved) {
|
|
||||||
const validationError = validateUrl(saved);
|
|
||||||
if (validationError) {
|
|
||||||
setError(validationError);
|
|
||||||
} else {
|
|
||||||
setCurrentUrl(saved);
|
|
||||||
setError(null);
|
|
||||||
setIframeLoading(true); // 重置 iframe 加载状态
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(null);
|
|
||||||
setCurrentUrl('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load current URL:', error);
|
|
||||||
setError('加载 URL 失败');
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
|
||||||
loadCurrentUrl();
|
|
||||||
}, [loadCurrentUrl]);
|
|
||||||
|
|
||||||
// 监听存储变化,确保 URL 变更时能及时更新
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
|
||||||
if (changes['openUrl/currentUrl']) {
|
|
||||||
loadCurrentUrl();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
|
||||||
}, [loadCurrentUrl]);
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress size={40} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, flex: 1 }}>
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
<Typography color="text.secondary">请返回 OpenUrl 页面选择有效的 URL。</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUrl) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, flex: 1 }}>
|
|
||||||
<Alert severity="info" sx={{ mb: 2 }}>
|
|
||||||
没有选中的 URL
|
|
||||||
</Alert>
|
|
||||||
<Typography color="text.secondary">请先在 OpenUrl 页面选择一个 URL 打开。</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 加载状态指示器 */}
|
|
||||||
{iframeLoading && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<CircularProgress size={60} />
|
|
||||||
<Typography sx={{ mt: 2 }}>加载中...</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<iframe
|
|
||||||
src={currentUrl}
|
|
||||||
title="OpenUrl Viewer"
|
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-navigation"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
width: '100%',
|
|
||||||
border: 'none',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
onLoad={() => setIframeLoading(false)}
|
|
||||||
onError={() => {
|
|
||||||
setIframeLoading(false);
|
|
||||||
setError('URL 加载失败,请检查网络连接或 URL 是否正确');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Box, Stack, Container, CircularProgress } from '@mui/material';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
|
|
||||||
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
|
|
||||||
export default function QrCodePage() {
|
|
||||||
const { showMessage } = useGlobalSnackbar();
|
|
||||||
|
|
||||||
// 使用自定义钩子管理展开状态
|
|
||||||
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
|
||||||
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
|
||||||
|
|
||||||
// 初始化未完成时显示加载状态
|
|
||||||
if (!urlInitialized || !qrInitialized) {
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
sx={{
|
|
||||||
py: 4,
|
|
||||||
maxWidth: 400,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2, maxWidth: 400 }}>
|
|
||||||
<PageHeader
|
|
||||||
title="二维码工具"
|
|
||||||
subtitle="生成和解析二维码"
|
|
||||||
icon={<QrCodeIcon />}
|
|
||||||
iconColor={qrCodePageStyles.primaryColor}
|
|
||||||
sx={{ mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<UrlToQrCodeSection
|
|
||||||
expanded={urlExpanded}
|
|
||||||
onExpandedChange={setUrlExpanded}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<QrCodeToUrlSection
|
|
||||||
expanded={qrExpanded}
|
|
||||||
onExpandedChange={setQrExpanded}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { Box, Container, CircularProgress } from '@mui/material';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
|
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
|
||||||
import { useStorageCleaner } from './useStorageCleaner';
|
|
||||||
import DomainHeader from './components/DomainHeader';
|
|
||||||
import StorageOptionsGrid from './components/StorageOptionsGrid';
|
|
||||||
import AutoRefreshToggle from './components/AutoRefreshToggle';
|
|
||||||
import ErrorDisplay from './components/ErrorDisplay';
|
|
||||||
import CleaningResult from './components/CleaningResult';
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const { showMessage } = useGlobalSnackbar();
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
error,
|
|
||||||
isInitializing,
|
|
||||||
options,
|
|
||||||
sizes,
|
|
||||||
autoRefresh,
|
|
||||||
loading,
|
|
||||||
result,
|
|
||||||
showConfirm,
|
|
||||||
setShowConfirm,
|
|
||||||
totalSize,
|
|
||||||
allSelected,
|
|
||||||
someSelected,
|
|
||||||
handleAutoRefreshChange,
|
|
||||||
handleOptionChange,
|
|
||||||
handleSelectAll,
|
|
||||||
handleClean,
|
|
||||||
} = useStorageCleaner({ showMessage });
|
|
||||||
|
|
||||||
if (isInitializing) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
||||||
<CircularProgress size={24} color="warning" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <ErrorDisplay error={error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<DomainHeader domain={domain} totalSize={totalSize} />
|
|
||||||
|
|
||||||
<StorageOptionsGrid
|
|
||||||
options={options}
|
|
||||||
sizes={sizes}
|
|
||||||
allSelected={allSelected}
|
|
||||||
someSelected={someSelected}
|
|
||||||
onOptionChange={handleOptionChange}
|
|
||||||
onSelectAll={handleSelectAll}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
sx={{
|
|
||||||
bgcolor: storageCleanerPageStyles.warningColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: storageCleanerPageStyles.warningDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '正在清理...' : '立即清理'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<CleaningResult result={result} />
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<StorageCleanerConfirm
|
|
||||||
open={showConfirm}
|
|
||||||
onClose={() => setShowConfirm(false)}
|
|
||||||
onConfirm={handleClean}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
|
|
||||||
import { useSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { ZONES, timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import LiveClock from './components/LiveClock';
|
|
||||||
import ResultView from './components/ResultView';
|
|
||||||
import { useTimestampConverter } from './hooks/useTimestampConverter';
|
|
||||||
|
|
||||||
export default function TimestampPage() {
|
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const {
|
|
||||||
mode,
|
|
||||||
tsInput,
|
|
||||||
dtInput,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
setMode,
|
|
||||||
setTsInput,
|
|
||||||
setDtInput,
|
|
||||||
setUnit,
|
|
||||||
setZone,
|
|
||||||
handleUseNow,
|
|
||||||
convert,
|
|
||||||
} = useTimestampConverter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ p: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="时间戳转换"
|
|
||||||
subtitle="Unix 毫秒数转换与格式化"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Live Clock Card */}
|
|
||||||
<LiveClock
|
|
||||||
unit={unit}
|
|
||||||
onUseNow={handleUseNow}
|
|
||||||
onUnitChange={setUnit}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Mode Switcher */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
p: 0.6,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
mb: 2.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
height: 'calc(100% - 10px)',
|
|
||||||
width: 'calc(50% - 5px)',
|
|
||||||
bgcolor: '#fff',
|
|
||||||
borderRadius: 3.5,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
|
||||||
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
|
||||||
top: 5,
|
|
||||||
left: 5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
|
||||||
<Box
|
|
||||||
key={m}
|
|
||||||
onClick={() => setMode(m)}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
py: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 1,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: mode === m ? 'primary.main' : 'text.secondary',
|
|
||||||
transition: 'color 0.3s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
|
||||||
<TextField
|
|
||||||
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
|
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(val);
|
|
||||||
} else {
|
|
||||||
setDtInput(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error}
|
|
||||||
fullWidth
|
|
||||||
sx={timestampPageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1.5}>
|
|
||||||
{/* 优化后的单位选择按钮组 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
p: 0.5,
|
|
||||||
borderRadius: 3.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(['ms', 's'] as const).map((u) => (
|
|
||||||
<Box
|
|
||||||
key={u}
|
|
||||||
onClick={() => setUnit(u)}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
py: 0.8,
|
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: 3,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 800,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
bgcolor: unit === u ? '#fff' : 'transparent',
|
|
||||||
color: unit === u ? 'primary.main' : 'text.disabled',
|
|
||||||
boxShadow: unit === u ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u === 'ms' ? '毫秒 (ms)' : '秒 (s)'}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
fullWidth
|
|
||||||
value={zone}
|
|
||||||
onChange={(e) => setZone(e.target.value as typeof zone)}
|
|
||||||
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1, borderRadius: 4 }}
|
|
||||||
MenuProps={{
|
|
||||||
PaperProps: {
|
|
||||||
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ZONES.map((z) => (
|
|
||||||
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
|
|
||||||
{z}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Main Action */}
|
|
||||||
<Button fullWidth variant="contained" onClick={convert}>
|
|
||||||
立即转换
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Result View */}
|
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Box, Switch, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
interface AutoRefreshToggleProps {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mb: 3,
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
|
|
||||||
清理后自动刷新页面
|
|
||||||
</Typography>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
color="warning"
|
|
||||||
sx={{
|
|
||||||
'& .MuiSwitch-track': {
|
|
||||||
borderRadius: 20,
|
|
||||||
},
|
|
||||||
'& .MuiSwitch-thumb': {
|
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
},
|
|
||||||
'&:hover .MuiSwitch-thumb': {
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Box, Alert } from '@mui/material';
|
|
||||||
import type { CleaningResult } from '@/types/storage';
|
|
||||||
import { formatCleaningResult } from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
interface CleaningResultProps {
|
|
||||||
result: CleaningResult | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CleaningResult({ result }: CleaningResultProps) {
|
|
||||||
if (!result) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
py: 1,
|
|
||||||
px: 2,
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
||||||
'& .MuiAlert-message': {
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
},
|
|
||||||
'& .MuiAlert-icon': {
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
mr: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DomainHeader 组件属性接口
|
|
||||||
*/
|
|
||||||
interface DomainHeaderProps {
|
|
||||||
/** 当前域名 */
|
|
||||||
domain: string;
|
|
||||||
/** 已占用的存储大小(字节) */
|
|
||||||
totalSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DomainHeader - 存储清理页面标题栏组件
|
|
||||||
*
|
|
||||||
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <DomainHeader
|
|
||||||
* domain="example.com"
|
|
||||||
* totalSize={1048576}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
|
||||||
return (
|
|
||||||
<PageHeader
|
|
||||||
icon={<StorageIcon sx={{ fontSize: 22 }} />}
|
|
||||||
iconColor={storageCleanerPageStyles.warningColor}
|
|
||||||
title="存储清理"
|
|
||||||
subtitle={domain || '加载中...'}
|
|
||||||
badge={
|
|
||||||
totalSize > 0 ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
|
||||||
color: storageCleanerPageStyles.warningColor,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.3,
|
|
||||||
borderRadius: 2,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.25)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已占用 {formatSize(totalSize)}
|
|
||||||
</Box>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
iconSx={{
|
|
||||||
p: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
titleSx={{
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
subtitleSx={{
|
|
||||||
display: 'block',
|
|
||||||
maxWidth: 240,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
mt: 0.3,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
}}
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Box, Container, Typography } from '@mui/material';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
|
|
||||||
interface ErrorDisplayProps {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
sx={{
|
|
||||||
py: 8,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '400px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
p: 4,
|
|
||||||
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
|
|
||||||
border: '1px solid rgba(244, 67, 54, 0.2)',
|
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
color="error.main"
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
mb: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
存储清理功能仅适用于标准网页
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
||||||
import { Stack, Typography, Box, IconButton, Tooltip, Divider, alpha } from '@mui/material';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface LiveClockProps {
|
|
||||||
unit: UnitType;
|
|
||||||
onUseNow: (val: number) => void;
|
|
||||||
onUnitChange: (u: UnitType) => void;
|
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const onUseNowRef = useRef(onUseNow);
|
|
||||||
const showMessageRef = useRef(showMessage);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onUseNowRef.current = onUseNow;
|
|
||||||
showMessageRef.current = showMessage;
|
|
||||||
}, [onUseNow, showMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayVal = useMemo(
|
|
||||||
() => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
[now, unit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUseNow = useCallback(() => {
|
|
||||||
onUseNowRef.current(now);
|
|
||||||
showMessageRef.current?.('已使用当前时间戳', { severity: 'success' });
|
|
||||||
}, [now, showMessageRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 1.8,
|
|
||||||
mb: 2.5,
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.04),
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack spacing={0.5}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
当前时间戳
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 800,
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
letterSpacing: '-0.5px',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayVal}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
|
||||||
{/* 胶囊式单位切换器 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
p: 0.4,
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.08),
|
|
||||||
borderRadius: 2.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(['ms', 's'] as const).map((u) => (
|
|
||||||
<Box
|
|
||||||
key={u}
|
|
||||||
onClick={() => onUnitChange(u)}
|
|
||||||
sx={{
|
|
||||||
px: 1.2,
|
|
||||||
py: 0.35,
|
|
||||||
borderRadius: 2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
bgcolor: unit === u ? '#fff' : 'transparent',
|
|
||||||
color: unit === u ? 'primary.main' : alpha(timestampPageStyles.primaryColor, 0.4),
|
|
||||||
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.toUpperCase()}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider
|
|
||||||
orientation="vertical"
|
|
||||||
flexItem
|
|
||||||
sx={{ mx: 0.5, my: 1, borderColor: alpha(timestampPageStyles.primaryColor, 0.1) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="填充到下方">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleUseNow}
|
|
||||||
sx={{
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
bgcolor: '#fff',
|
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
|
||||||
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccessTimeIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<CopyButton
|
|
||||||
text={displayVal}
|
|
||||||
tooltip="复制时间戳"
|
|
||||||
size="small"
|
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
export default LiveClock;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { Box, Checkbox, Typography } from '@mui/material';
|
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
interface OptionItemProps {
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
size?: number;
|
|
||||||
isCount?: boolean;
|
|
||||||
onChange: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OptionItem({
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
size,
|
|
||||||
isCount = false,
|
|
||||||
onChange,
|
|
||||||
}: OptionItemProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
py: 1,
|
|
||||||
px: 1.5,
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
|
|
||||||
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight={700}
|
|
||||||
color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
display: 'block',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
transition: 'color 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
{size !== undefined && size > 0 ? (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.3,
|
|
||||||
lineHeight: 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
opacity: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isCount ? `${size} 个` : formatSize(size)}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'grey.400',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.3,
|
|
||||||
lineHeight: 1,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
无数据
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={checked}
|
|
||||||
onChange={onChange}
|
|
||||||
color="warning"
|
|
||||||
sx={{
|
|
||||||
p: 0.6,
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
fontSize: 18,
|
|
||||||
transition: 'transform 0.2s',
|
|
||||||
},
|
|
||||||
'&:hover .MuiSvgIcon-root': {
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import { Typography, Box, Fade, Stack, alpha } from '@mui/material';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface ResultViewProps {
|
|
||||||
result: string;
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
|
|
||||||
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 (
|
|
||||||
<Fade in={!!result}>
|
|
||||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
mb: 1.2,
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
转换结果
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
position: 'relative',
|
|
||||||
mb: 2.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
pr: 4,
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result}
|
|
||||||
</Typography>
|
|
||||||
<CopyButton
|
|
||||||
text={result}
|
|
||||||
tooltip="复制结果"
|
|
||||||
size="small"
|
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
spacing={1.2}
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.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.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Typography>
|
|
||||||
{item.value && (
|
|
||||||
<CopyButton
|
|
||||||
text={item.value}
|
|
||||||
tooltip="复制"
|
|
||||||
size="small"
|
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
|
||||||
|
|
||||||
export default ResultView;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
|
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
|
||||||
import OptionItem from './OptionItem';
|
|
||||||
|
|
||||||
interface StorageOptionsGridProps {
|
|
||||||
options: StorageCleanerOptions;
|
|
||||||
sizes: Record<string, number>;
|
|
||||||
allSelected: boolean;
|
|
||||||
someSelected: boolean;
|
|
||||||
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
|
||||||
onSelectAll: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StorageOptionsGrid({
|
|
||||||
options,
|
|
||||||
sizes,
|
|
||||||
allSelected,
|
|
||||||
someSelected,
|
|
||||||
onOptionChange,
|
|
||||||
onSelectAll,
|
|
||||||
}: StorageOptionsGridProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mb: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
overflow: 'hidden',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 1.2 }}>
|
|
||||||
<Grid container spacing={1.5}>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="LocalStorage"
|
|
||||||
checked={options.localStorage}
|
|
||||||
size={sizes.localStorage}
|
|
||||||
onChange={() => onOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Session Storage"
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
size={sizes.sessionStorage}
|
|
||||||
onChange={() => onOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="IndexedDB"
|
|
||||||
checked={options.indexedDB}
|
|
||||||
size={sizes.indexedDB}
|
|
||||||
onChange={() => onOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Cookies"
|
|
||||||
checked={options.cookies}
|
|
||||||
size={sizes.cookies}
|
|
||||||
onChange={() => onOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Cache Storage"
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
size={sizes.cacheStorage}
|
|
||||||
isCount
|
|
||||||
onChange={() => onOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Service Workers"
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
size={sizes.serviceWorkers}
|
|
||||||
isCount
|
|
||||||
onChange={() => onOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
px: 2.7,
|
|
||||||
py: 0.8,
|
|
||||||
borderBottomLeftRadius: 4,
|
|
||||||
borderBottomRightRadius: 4,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight={700}
|
|
||||||
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
|
|
||||||
>
|
|
||||||
全选所有项
|
|
||||||
</Typography>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={allSelected}
|
|
||||||
indeterminate={someSelected}
|
|
||||||
onChange={(e) => onSelectAll(e.target.checked)}
|
|
||||||
color="warning"
|
|
||||||
sx={{
|
|
||||||
p: 0.6,
|
|
||||||
mr: 0,
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
fontSize: 18,
|
|
||||||
transition: 'transform 0.2s',
|
|
||||||
},
|
|
||||||
'&:hover .MuiSvgIcon-root': {
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { getActiveTabDomain } from '@/utils/chromeTabs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动获取并维护当前活动标签页域名的 Hook
|
|
||||||
*/
|
|
||||||
export function useActiveTabDomain() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getActiveTabDomain().then(setDomain);
|
|
||||||
|
|
||||||
// 如果需要实时同步(如切换标签页),可以监听 chrome.tabs.onActivated
|
|
||||||
const handleActivated = () => getActiveTabDomain().then(setDomain);
|
|
||||||
const handleUpdated = (_: number, changeInfo: { url?: string }) => {
|
|
||||||
if (changeInfo.url) getActiveTabDomain().then(setDomain);
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.tabs.onActivated.addListener(handleActivated);
|
|
||||||
chrome.tabs.onUpdated.addListener(handleUpdated);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chrome.tabs.onActivated.removeListener(handleActivated);
|
|
||||||
chrome.tabs.onUpdated.removeListener(handleUpdated);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
|
|
||||||
import { FillMode } from '@/utils/dummyDataGenerator';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import { FieldTypePreferences } from '@/types/storage';
|
|
||||||
import { useActiveTabDomain } from './useActiveTabDomain';
|
|
||||||
import { useSidePanelState } from './useSidePanelState';
|
|
||||||
|
|
||||||
// 字段数据接口
|
|
||||||
export interface FieldData {
|
|
||||||
id: string;
|
|
||||||
fieldType: string;
|
|
||||||
label: string | null;
|
|
||||||
placeholder: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
generatedValue: string;
|
|
||||||
useInvalidData?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
|
|
||||||
|
|
||||||
export function useFormRecognizer() {
|
|
||||||
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 1500 });
|
|
||||||
const [fillLoading, setFillLoading] = useState(false);
|
|
||||||
const [clearLoading, setClearLoading] = useState(false);
|
|
||||||
const [includeHidden, setIncludeHidden] = useState(false);
|
|
||||||
const isProcessingRef = useRef(false);
|
|
||||||
const [fields, setFields] = useState<FieldData[]>([]);
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [showFields, setShowFields] = useState(false);
|
|
||||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const currentDomain = useActiveTabDomain();
|
|
||||||
const { sidePanelOpen, handleOpenSidePanel } = useSidePanelState();
|
|
||||||
|
|
||||||
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
|
|
||||||
'formRecognizer/fieldTypePreferences',
|
|
||||||
DEFAULT_FIELD_TYPE_PREFERENCES,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 生成字段标识符
|
|
||||||
const getFieldIdentifier = useCallback(
|
|
||||||
(field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
|
|
||||||
return field.label || field.name || field.placeholder || 'unknown';
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 应用保存的类型偏好
|
|
||||||
const applySavedPreferences = useCallback(
|
|
||||||
(fields: FieldData[], domain: string, preferences: FieldTypePreferences): FieldData[] => {
|
|
||||||
if (!domain || !preferences[domain]) {
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
const prefs = preferences[domain];
|
|
||||||
return fields.map((field) => {
|
|
||||||
const identifier = getFieldIdentifier(field);
|
|
||||||
if (prefs && prefs[identifier]) {
|
|
||||||
return { ...field, fieldType: prefs[identifier] };
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[getFieldIdentifier],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存类型偏好
|
|
||||||
const saveTypePreference = useCallback(
|
|
||||||
(field: FieldData, newType: string) => {
|
|
||||||
if (!currentDomain) return;
|
|
||||||
const identifier = getFieldIdentifier(field);
|
|
||||||
setFieldTypePreferences((prev) => {
|
|
||||||
const prevPrefs = prev as FieldTypePreferences;
|
|
||||||
const currentDomainPrefs = prevPrefs[currentDomain] || {};
|
|
||||||
return {
|
|
||||||
...prevPrefs,
|
|
||||||
[currentDomain]: {
|
|
||||||
...currentDomainPrefs,
|
|
||||||
[identifier]: newType,
|
|
||||||
},
|
|
||||||
} as FieldTypePreferences;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[currentDomain, getFieldIdentifier, setFieldTypePreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 扫描表单字段
|
|
||||||
const handleScanFields = async () => {
|
|
||||||
setScanning(true);
|
|
||||||
try {
|
|
||||||
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
|
||||||
|
|
||||||
if (!response.success && response.message && response.message.includes('无法连接')) {
|
|
||||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
|
||||||
const injected = await injectContentScript();
|
|
||||||
if (injected) {
|
|
||||||
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success && response.fields) {
|
|
||||||
const fieldsWithPreferences = applySavedPreferences(
|
|
||||||
response.fields as FieldData[],
|
|
||||||
currentDomain,
|
|
||||||
fieldTypePreferences,
|
|
||||||
);
|
|
||||||
setFields(fieldsWithPreferences);
|
|
||||||
setShowFields(true);
|
|
||||||
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
showMessage(response.message || '扫描失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('扫描失败:', error);
|
|
||||||
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新字段类型
|
|
||||||
const handleFieldTypeChange = (fieldId: string, newType: string) => {
|
|
||||||
setFields((prev) =>
|
|
||||||
prev.map((field) => {
|
|
||||||
if (field.id === fieldId) {
|
|
||||||
const updatedField = {
|
|
||||||
...field,
|
|
||||||
fieldType: newType,
|
|
||||||
generatedValue: '',
|
|
||||||
};
|
|
||||||
saveTypePreference(field, newType);
|
|
||||||
return updatedField;
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换单个字段的选中状态
|
|
||||||
const handleToggleFieldSelection = (fieldId: string) => {
|
|
||||||
setFields((prev) =>
|
|
||||||
prev.map((field) =>
|
|
||||||
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 全选/取消全选
|
|
||||||
const handleToggleAllFields = () => {
|
|
||||||
setFields((prev) => {
|
|
||||||
const allSelected = prev.every((f) => f.isSelected);
|
|
||||||
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定位字段(闪烁)
|
|
||||||
const handleLocateField = async (fieldId: string) => {
|
|
||||||
try {
|
|
||||||
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
|
|
||||||
if (!response.success) {
|
|
||||||
showMessage(response.message || '定位字段失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('定位字段失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 悬停高亮
|
|
||||||
const handleHoverField = async (fieldId: string | null) => {
|
|
||||||
setHoveredFieldId(fieldId);
|
|
||||||
try {
|
|
||||||
if (fieldId) {
|
|
||||||
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
|
|
||||||
} else {
|
|
||||||
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('高亮字段失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 填充选中字段
|
|
||||||
const handleFillSelectedFields = async () => {
|
|
||||||
if (isProcessingRef.current) {
|
|
||||||
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
|
||||||
if (selectedCount === 0) {
|
|
||||||
showMessage('请先选择要填充的字段', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFillLoading(true);
|
|
||||||
isProcessingRef.current = true;
|
|
||||||
try {
|
|
||||||
const messageFields = fields as MessageFieldData[];
|
|
||||||
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
|
||||||
fields: messageFields,
|
|
||||||
mode: FillMode.VALID,
|
|
||||||
includeHidden,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.success && response.message && response.message.includes('无法连接')) {
|
|
||||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
|
||||||
const injected = await injectContentScript();
|
|
||||||
if (injected) {
|
|
||||||
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
|
||||||
fields: messageFields,
|
|
||||||
mode: FillMode.VALID,
|
|
||||||
includeHidden,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
showMessage(response.message || '填充成功', { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
showMessage(response.message || '填充失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('填充失败:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
|
||||||
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setFillLoading(false);
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空所有字段
|
|
||||||
const handleClearAllFields = async () => {
|
|
||||||
if (isProcessingRef.current) {
|
|
||||||
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setClearLoading(true);
|
|
||||||
isProcessingRef.current = true;
|
|
||||||
try {
|
|
||||||
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
|
|
||||||
|
|
||||||
if (!response.success && response.message && response.message.includes('无法连接')) {
|
|
||||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
|
||||||
const injected = await injectContentScript();
|
|
||||||
if (injected) {
|
|
||||||
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
showMessage(response.message || '清空成功', { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
showMessage(response.message || '清空失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清空失败:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
|
||||||
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setClearLoading(false);
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
fillLoading,
|
|
||||||
clearLoading,
|
|
||||||
includeHidden,
|
|
||||||
setIncludeHidden,
|
|
||||||
fields,
|
|
||||||
scanning,
|
|
||||||
showFields,
|
|
||||||
setShowFields,
|
|
||||||
hoveredFieldId,
|
|
||||||
sidePanelOpen,
|
|
||||||
handleScanFields,
|
|
||||||
handleFieldTypeChange,
|
|
||||||
handleToggleFieldSelection,
|
|
||||||
handleToggleAllFields,
|
|
||||||
handleLocateField,
|
|
||||||
handleHoverField,
|
|
||||||
handleFillSelectedFields,
|
|
||||||
handleClearAllFields,
|
|
||||||
handleOpenSidePanel,
|
|
||||||
selectedCount: fields.filter((f) => f.isSelected).length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
|
||||||
import { useSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理侧边栏状态检测与开启逻辑的 Hook
|
|
||||||
*/
|
|
||||||
export function useSidePanelState() {
|
|
||||||
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
|
|
||||||
const checkSidePanelState = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (typeof chrome.runtime.getContexts === 'function') {
|
|
||||||
const contexts = await chrome.runtime.getContexts({
|
|
||||||
contextTypes: ['SIDE_PANEL'],
|
|
||||||
});
|
|
||||||
setSidePanelOpen(contexts.length > 0);
|
|
||||||
} else {
|
|
||||||
setSidePanelOpen(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检测侧边栏状态失败:', error);
|
|
||||||
setSidePanelOpen(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 使用 requestAnimationFrame 避免同步调用 setState
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
checkSidePanelState().catch(console.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听侧边栏状态变化消息
|
|
||||||
const removeListener = onMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, (message) => {
|
|
||||||
setSidePanelOpen(message.data.isOpen);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removeListener();
|
|
||||||
};
|
|
||||||
}, [checkSidePanelState]);
|
|
||||||
|
|
||||||
const handleOpenSidePanel = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
|
|
||||||
setSidePanelOpen(true);
|
|
||||||
showMessage('侧边栏已打开', { severity: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('打开侧边栏失败:', error);
|
|
||||||
showMessage('打开侧边栏失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
}, [showMessage]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sidePanelOpen,
|
|
||||||
handleOpenSidePanel,
|
|
||||||
checkSidePanelState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
|
||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
|
||||||
// State
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
tsInput: string;
|
|
||||||
dtInput: string;
|
|
||||||
unit: UnitType;
|
|
||||||
zone: ZoneType;
|
|
||||||
result: string;
|
|
||||||
error: string;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
|
||||||
setTsInput: (value: string) => void;
|
|
||||||
setDtInput: (value: string) => void;
|
|
||||||
setUnit: (unit: UnitType) => void;
|
|
||||||
setZone: (zone: ZoneType) => void;
|
|
||||||
handleUseNow: (now: number) => void;
|
|
||||||
convert: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
|
||||||
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 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]);
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => {
|
|
||||||
setMode(newMode);
|
|
||||||
setError('');
|
|
||||||
setResult('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSetTsInput = useCallback((value: string) => {
|
|
||||||
setTsInput(value);
|
|
||||||
setError('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSetDtInput = useCallback((value: string) => {
|
|
||||||
setDtInput(value);
|
|
||||||
setError('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode,
|
|
||||||
tsInput,
|
|
||||||
dtInput,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
setMode: handleSetMode,
|
|
||||||
setTsInput: handleSetTsInput,
|
|
||||||
setDtInput: handleSetDtInput,
|
|
||||||
setUnit,
|
|
||||||
setZone,
|
|
||||||
handleUseNow,
|
|
||||||
convert,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,40 +3,33 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const handleOpenOptions = () => {
|
const handleOpenOptions = () => {
|
||||||
chrome.runtime.openOptionsPage();
|
chrome.runtime.openOptionsPage().catch((err) => {
|
||||||
|
console.error('Failed to open options page:', err);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 通知侧边栏已打开
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||||
return () => {
|
return () => {
|
||||||
// 尝试在关闭时通知,虽然在某些情况下可能无法成功发送
|
|
||||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
|
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
<Box
|
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||||
className="app"
|
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||||
sx={{
|
<TopBar onOpenOptions={handleOpenOptions} />
|
||||||
display: 'flex',
|
<ErrorBoundary>
|
||||||
flexDirection: 'column',
|
<RouterContainer />
|
||||||
height: '100vh',
|
</ErrorBoundary>
|
||||||
width: '100%',
|
</div>
|
||||||
overflow: 'hidden',
|
</SnackbarProvider>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TopBar onOpenOptions={handleOpenOptions} />
|
|
||||||
<ErrorBoundary>
|
|
||||||
<RouterContainer />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Box>
|
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import AppRoot from '@/providers/AppRoot';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import '@/i18n';
|
||||||
import theme from '@/config/theme';
|
import '@/src/index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<ThemeProvider theme={theme}>
|
<App />
|
||||||
<CssBaseline />
|
</AppRoot>,
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
+50
-15
@@ -4,20 +4,29 @@ import reactHooks from 'eslint-plugin-react-hooks';
|
|||||||
import reactPlugin from 'eslint-plugin-react';
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default [
|
export default tseslint.config(
|
||||||
|
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||||
'dist',
|
|
||||||
'.wxt',
|
|
||||||
'node_modules',
|
|
||||||
'eslint.config.ts',
|
|
||||||
'**/*.test.tsx',
|
|
||||||
'**/*.test.ts',
|
|
||||||
'**/__tests__/**',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.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}',
|
||||||
@@ -27,29 +36,55 @@ export default [
|
|||||||
'components/**/*.{ts,tsx}',
|
'components/**/*.{ts,tsx}',
|
||||||
'services/**/*.{ts,tsx}',
|
'services/**/*.{ts,tsx}',
|
||||||
],
|
],
|
||||||
|
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,
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user