Compare commits
335 Commits
v1.0.3
...
43038c5edb
| Author | SHA1 | Date | |
|---|---|---|---|
| 43038c5edb | |||
| 9aa7a51f0b | |||
| d21e970f56 | |||
| 6589ea3858 | |||
| 6be9b31ca2 | |||
| 6975e38f79 | |||
| 17a4dbd8a7 | |||
| 27e03ae038 | |||
| c7f4305fc3 | |||
| d18ae5d41c | |||
| 47e08f80b3 | |||
| ece5c6135e | |||
| d0825c5ec0 | |||
| 149883565d | |||
| ff3c66531c | |||
| 4084dafc29 | |||
| 647e3dd308 | |||
| 2967a6f79f | |||
| 92bca1c728 | |||
| ce0af7dff3 | |||
| d38bafe237 | |||
| f3a9838017 | |||
| 0545910b16 | |||
| 1948ba67a7 | |||
| 4b38133cf8 | |||
| 5ae9cf065f | |||
| 1041b3ece1 | |||
| edd4cf266c | |||
| ebf9f7a153 | |||
| c98d09d14c | |||
| bf06b61f10 | |||
| 0f3060dcf3 | |||
| 47d74f999e | |||
| ed383dd319 | |||
| e1b3a26a2c | |||
| e372fc6855 | |||
| 9d6213e75e | |||
| 228cadf526 | |||
| 06b9d29270 | |||
| 0906d1f425 | |||
| 9c993b6cea | |||
| ca0143f3bd | |||
| 3ab7194501 | |||
| d401744739 | |||
| 2c901ab4fd | |||
| a8a1e89f8a | |||
| 9097c48b1f | |||
| c69a26b469 | |||
| cd21d11fa1 | |||
| f0292acbb7 | |||
| 8f9ff27034 | |||
| aa63723b80 | |||
| ec92afa294 | |||
| 618f3c36f2 | |||
| 24c83de56d | |||
| 9c5f5c8cfe | |||
| 016173a9c5 | |||
| 30d3561726 | |||
| 1f9288f780 | |||
| 355a94d116 | |||
| 3f73adc015 | |||
| a163297de4 | |||
| 86870864a9 | |||
| 1a285fc4c1 | |||
| 9a0c8ba850 | |||
| 6c7b7294b4 | |||
| a2e07a9115 | |||
| 2464a5fea6 | |||
| 78f5292b00 | |||
| 5a2589cc57 | |||
| 64db60d75e | |||
| 7090ba3251 | |||
| 31b6edf06b | |||
| a374bf456d | |||
| ea10b27c14 | |||
| 40443c8fc5 | |||
| ee4a45630e | |||
| 4a423889d9 | |||
| ee78e314d3 | |||
| ee77c97b56 | |||
| aa8b84af50 | |||
| 707f17ac84 | |||
| af0b2d8ed1 | |||
| 49e7f1ea55 | |||
| 1a92e88934 | |||
| 2b84eda24f | |||
| a5d86a92c2 | |||
| 7f22b95aa2 | |||
| 9990d86270 | |||
| ea0552e8aa | |||
| 97098c3abe | |||
| 7afce94d93 | |||
| e2b76a5f86 | |||
| 1ab79785b6 | |||
| c40b66b703 | |||
| f55fd4338d | |||
| 27c1cf37e3 | |||
| 2520d72eb1 | |||
| 4d038203a7 | |||
| c992986789 | |||
| 2c45ddada9 | |||
| 70b799aa2d | |||
| 58af2af37b | |||
| a0b1ade662 | |||
| d964e27c9d | |||
| 359326c6da | |||
| 10644fae95 | |||
| 77f722b575 | |||
| 0624b609be | |||
| f551428142 | |||
| 1ae057ccfa | |||
| 400a1afb0c | |||
| c6d0914d33 | |||
| 7617e317f2 | |||
| 396b3d7013 | |||
| 92fb853329 | |||
| e4e5464be0 | |||
| f4b6ae89b5 | |||
| 37bdeb5f3a | |||
| e919af6857 | |||
| 5be15301b5 | |||
| 306600a35c | |||
| a0345b384d | |||
| 9458d62a99 | |||
| 5b26287cff | |||
| 6dca673a5a | |||
| afd57bb5b2 | |||
| 3f447708b7 | |||
| f9821553b3 | |||
| 355ab0cf86 | |||
| 13113f0ea2 | |||
| 0d69695dbb | |||
| 093bf278da | |||
| f9fbc79612 | |||
| 1b9f6d044d | |||
| b41cfb2a02 | |||
| 93019f56cf | |||
| 2727e60025 | |||
| c2e54b34cb | |||
| dbdec710ef | |||
| 7bd52bfc2b | |||
| c9fe27a26b | |||
| e97f4cae9a | |||
| 21a6430f0d | |||
| dff2705ef8 | |||
| f8c48a3047 | |||
| 488e2e8e49 | |||
| 98abe59731 | |||
| 77168e2652 | |||
| 4f7e402dba | |||
| e33761381d | |||
| 945780def8 | |||
| 6bddc4fcfc | |||
| 3df45f491c | |||
| 0054e97a0a | |||
| 97159cb799 | |||
| 5175677b24 | |||
| 3400ffc9dc | |||
| 4955f617ee | |||
| 2cb5b0c1e7 | |||
| c6cadda95b | |||
| 067a20fb0a | |||
| ca4bfc33fd | |||
| e140af7698 | |||
| b79fe7dd49 | |||
| 9903483f42 | |||
| 08911d4cba | |||
| 1daf049737 | |||
| 8b6bac9f2c | |||
| aa23a263fe | |||
| d4c29a1bf4 | |||
| 64e23994a6 | |||
| 528621f16e | |||
| c735a0aa1a | |||
| 62c880b0a8 | |||
| e263b63ced | |||
| 1e4b6eddc7 | |||
| 4316bb57d2 | |||
| f40485e2dc | |||
| 0908358f97 | |||
| a3f89d07ea | |||
| 003dad5d8b | |||
| 27d3360f9d | |||
| 67629263f9 | |||
| 8b58dcf02e | |||
| 2e2f6e9835 | |||
| cdad9bda68 | |||
| fae8dea971 | |||
| e0de152645 | |||
| e005d4d95d | |||
| 601260797e | |||
| c61b599287 | |||
| 42d3fedbaa | |||
| 0f3b6c4cd8 | |||
| af2262d2c9 | |||
| e5ae802ab2 | |||
| dec43f89da | |||
| 61e741f198 | |||
| c98f767809 | |||
| 92d524d832 | |||
| 63f47fd2b1 | |||
| 6abdf4add4 | |||
| e95ca1297c | |||
| a0d6e76cad | |||
| cc6d414d10 | |||
| c8ad2a2283 | |||
| 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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# CI/CD 配置
|
||||
|
||||
## CI 步骤
|
||||
|
||||
严格顺序,任一步骤失败则停止并标记 CI 失败:
|
||||
|
||||
1. `setup`(安装依赖、`wxt prepare`)
|
||||
2. 并行运行 `lint`、`typecheck`、`test`(三者全部通过才继续)
|
||||
3. `build`(仅当步骤 2 全部成功时执行)
|
||||
|
||||
## Pre-commit Hook
|
||||
|
||||
`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交:
|
||||
|
||||
1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误
|
||||
2. 同一代码文件:运行 `prettier --write`
|
||||
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||
@@ -13,9 +13,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
setup:
|
||||
name: Prepare Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-info.outputs.key }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -24,17 +26,50 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Cache Node Modules
|
||||
id: cache-nodemodules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-v22-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Output Cache Key
|
||||
id: cache-info
|
||||
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: TypeScript Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -43,17 +78,23 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Generate WXT types
|
||||
run: npx wxt prepare
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run compile
|
||||
run: npm run typecheck
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -62,10 +103,15 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Generate WXT types
|
||||
run: npx wxt prepare
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
@@ -76,7 +122,7 @@ jobs:
|
||||
needs: [ lint, typecheck, test ]
|
||||
strategy:
|
||||
matrix:
|
||||
browser: [chrome]
|
||||
browser: [ chrome, firefox ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -86,11 +132,20 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Build (Chrome)
|
||||
if: matrix.browser == 'chrome'
|
||||
run: npm run build
|
||||
- name: Generate WXT types
|
||||
run: npx wxt prepare
|
||||
|
||||
- name: Build Extension (${{ matrix.browser }})
|
||||
run: |
|
||||
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
||||
npm run build:${{ matrix.browser }}
|
||||
else
|
||||
npm run build -- --browser ${{ matrix.browser }}
|
||||
fi
|
||||
@@ -9,97 +9,142 @@ permissions:
|
||||
contents: write
|
||||
|
||||
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:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
- run: npm run lint
|
||||
|
||||
typecheck:
|
||||
name: TypeScript Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run compile
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
- run: npx wxt prepare
|
||||
- run: npm run typecheck
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
# ── Phase 2: 打包 & 发布 ─────────────────────────────────────────────
|
||||
release:
|
||||
name: Package & Release
|
||||
# ── Phase 3: 打包 ───────────────────────
|
||||
build-extension:
|
||||
name: Package Extension
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
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
|
||||
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
|
||||
- name: Sync version from tag
|
||||
run: |
|
||||
CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1)
|
||||
FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1)
|
||||
echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT"
|
||||
echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT"
|
||||
echo "Found Chrome zip: $CHROME_ZIP"
|
||||
echo "Found Firefox zip: $FIREFOX_ZIP"
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "Setting version to $VERSION"
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
|
||||
- name: Build and Zip Extension
|
||||
run: |
|
||||
npm run zip
|
||||
npm run zip:firefox
|
||||
|
||||
# 🔍 调试:确认 zip 文件位置
|
||||
- name: Verify output files
|
||||
run: |
|
||||
echo "=== .output/ contents ==="
|
||||
ls -la .output/ || echo "Not found"
|
||||
echo "=== All zip files ==="
|
||||
find . -name "*.zip" -type f
|
||||
|
||||
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
|
||||
- name: Prepare release artifacts
|
||||
run: |
|
||||
mkdir -p release-archives
|
||||
cp .output/*.zip release-archives/ 2>/dev/null || true
|
||||
echo "Files in release-archives:"
|
||||
ls -la release-archives/
|
||||
|
||||
- name: Upload Extension Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: extension-zips
|
||||
path: release-archives/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# ── Phase 4: 发布 ───────────────────────
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-extension
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Extension Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: extension-zips
|
||||
path: release-artifacts
|
||||
|
||||
# 🔍 调试:确认下载成功
|
||||
- name: Verify downloaded artifacts
|
||||
run: |
|
||||
echo "=== release-artifacts/ ==="
|
||||
ls -la release-artifacts/
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
@@ -114,5 +159,4 @@ jobs:
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
${{ steps.find_zips.outputs.chrome_zip }}
|
||||
${{ steps.find_zips.outputs.firefox_zip }}
|
||||
release-artifacts/*.zip
|
||||
|
||||
@@ -13,7 +13,11 @@ stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
.vitest
|
||||
.claude
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -28,3 +32,19 @@ stats-*.json
|
||||
|
||||
.trae/*
|
||||
.workbuddy/*
|
||||
.qoder/*
|
||||
dev/*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# IDE files
|
||||
*.code-workspace
|
||||
|
||||
# Other
|
||||
*.tsbuildinfo
|
||||
*.tsxbuildinfo
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
# 后台运行类型检查,结果输出到 stderr 但不阻塞提交
|
||||
npx tsc --noEmit &
|
||||
|
||||
# 前台运行 lint-staged(自动修复 + 格式化)
|
||||
npx lint-staged
|
||||
@@ -0,0 +1,13 @@
|
||||
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
||||
# 类型检查(全项目,因为类型错误可能跨文件传播)
|
||||
npx tsc --noEmit
|
||||
|
||||
# ESLint 严格检查(仅检查本次推送的变更文件,不阻塞不相关的旧代码)
|
||||
# 新分支无 upstream 时,回退到与 origin/main 对比
|
||||
MERGE_BASE=$(git merge-base HEAD @{upstream} 2>/dev/null || git merge-base HEAD origin/main 2>/dev/null)
|
||||
if [ -n "$MERGE_BASE" ]; then
|
||||
CHANGED_FILES=$(git diff --name-only --diff-filter=d "$MERGE_BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')
|
||||
if [ -n "$CHANGED_FILES" ]; then
|
||||
echo "$CHANGED_FILES" | xargs npx eslint --max-warnings=0
|
||||
fi
|
||||
fi
|
||||
@@ -1,315 +1,192 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `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
|
||||
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 # 运行单个测试文件
|
||||
npx wxt prepare # 重新生成 .wxt/ 类型声明(npm install 时 postinstall 会自动执行)
|
||||
```
|
||||
|
||||
**测试技术栈:**
|
||||
修改 `package.json` 或首次克隆仓库后需执行 `npm install`,会自动触发 `postinstall` → `wxt prepare`。
|
||||
|
||||
- Vitest v2 - 测试框架
|
||||
- @testing-library/react v16 - React 组件测试
|
||||
- @testing-library/user-event v14 - 用户交互模拟
|
||||
- jsdom v25 - 浏览器环境模拟
|
||||
## 验证流程
|
||||
|
||||
### 依赖与准备
|
||||
详见 [CI 配置](./.github/CI.md)。本地与 CI 的检查层次如下。
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
### Pre-commit(`.husky/pre-commit`)
|
||||
|
||||
## 项目架构
|
||||
1. 后台运行 `tsc --noEmit`(不阻塞提交,结果输出到 stderr)
|
||||
2. 前台运行 `lint-staged`:
|
||||
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):`eslint --fix --max-warnings=0`,再 `prettier --write`
|
||||
- 其他文件 (`*.{json,css,scss,md}`):`prettier --write`
|
||||
|
||||
### 技术栈
|
||||
### Pre-push(`.husky/pre-push`)
|
||||
|
||||
- **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: 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
|
||||
1. 全项目 `tsc --noEmit`(阻塞推送)
|
||||
2. 对本次推送相对 upstream(或 `origin/main`)变更的 `*.{ts,tsx,js,jsx,mjs}` 文件运行 `eslint --max-warnings=0`
|
||||
|
||||
### 目录结构
|
||||
### CI(GitHub Actions)
|
||||
|
||||
1. `setup` — 安装依赖
|
||||
2. 并行 `lint`、`typecheck`(含 `wxt prepare`)、`test`
|
||||
3. `build` — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── components/ # 可复用 UI 组件
|
||||
│ ├── __tests__/ # 组件测试文件
|
||||
│ ├── Button.tsx # 按钮组件
|
||||
│ ├── CopyButton.tsx # 复制按钮组件
|
||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件
|
||||
│ ├── FieldList.tsx # 字段列表组件
|
||||
│ ├── GlobalSnackbar.tsx # 全局提示消息组件
|
||||
│ ├── PageHeader.tsx # 页面头部组件
|
||||
│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件
|
||||
│ ├── 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 # 发布流程
|
||||
src/ # 源代码根目录
|
||||
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
|
||||
layout/ # 应用壳层布局(TopBar 导航、搜索、主题切换)
|
||||
pages/ # 功能页面组件 (懒加载)
|
||||
components/ # 可复用 UI 组件
|
||||
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||
providers/ # React Context (Router, Theme 等)
|
||||
hooks/ # 自定义 React Hooks
|
||||
utils/ # 工具函数与服务抽象
|
||||
types/ # TypeScript 类型声明
|
||||
lib/ # 通用工具函数(cn、utils)及数据生成器定义
|
||||
workers/ # Web Worker(数据生成等耗时任务)
|
||||
spec/ # 功能规格、修复方案与验收标准(见 spec/README.md)
|
||||
public/ # 静态资源(图标等)
|
||||
.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑)
|
||||
.output/ # 生产构建输出目录
|
||||
```
|
||||
|
||||
### 核心功能模块
|
||||
### layout/
|
||||
|
||||
#### 1. 时间戳转换工具
|
||||
应用壳层,与 popup / sidepanel / tab 入口绑定。当前含 `TopBar/`(搜索、主题切换、返回导航、「在标签页打开」),遵循与 `pages/` 相同的 UI + Hook 模式。详见 [layout/README.md](./src/layout/README.md)。
|
||||
|
||||
- 位置: `entrypoints/popup/pages/TimestampPage.tsx`
|
||||
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
|
||||
- 依赖: dayjs 库进行日期处理
|
||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
|
||||
### spec/
|
||||
|
||||
#### 2. 存储清理工具
|
||||
功能规格与验收标准文档,重大改动前优先查阅。索引见 [spec/README.md](./spec/README.md)。
|
||||
|
||||
- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
||||
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
|
||||
- 工具: `utils/storageCleaner.ts`
|
||||
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
|
||||
### 页面组件模式
|
||||
|
||||
#### 3. URL 管理工具
|
||||
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
|
||||
|
||||
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx`
|
||||
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
|
||||
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
|
||||
- 功能: 批量打开多个 URL,URL 列表管理
|
||||
|
||||
#### 4. 二维码工具
|
||||
|
||||
- 位置: `entrypoints/popup/pages/QrCodePage.tsx`
|
||||
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
|
||||
- 工具: `utils/qrCodeParser.ts`
|
||||
- 依赖: qrcode, jsqr 库
|
||||
- 功能: URL 转二维码生成,二维码图片解析为 URL
|
||||
|
||||
#### 5. 表单工具套件
|
||||
|
||||
**表单识别 (Form Recognizer)**
|
||||
|
||||
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx`
|
||||
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts`
|
||||
- 功能: 智能识别页面表单指纹
|
||||
|
||||
**表单映射 (Form Mapping)**
|
||||
|
||||
- 位置: `entrypoints/popup/pages/FormMappingPage.tsx`
|
||||
- 工具: `utils/formMapping/` 目录
|
||||
- `scanner.ts` - 表单扫描器
|
||||
- `highlighter.ts` - 表单高亮器
|
||||
- `smartInjector.ts` - 智能注入器
|
||||
- `ui.ts` - UI 工具
|
||||
- 功能: 表单指纹识别与自定义映射规则配置
|
||||
|
||||
**表单填充 (Form Fill)**
|
||||
|
||||
- 位置: `entrypoints/popup/pages/FormFillPage.tsx`
|
||||
- 工具: `utils/dummyDataGenerator.ts` (基于 @faker-js/faker)
|
||||
- 功能: 根据表单指纹智能填充表单数据
|
||||
|
||||
#### 6. 仪表盘系统
|
||||
|
||||
- 位置: `entrypoints/popup/pages/DashboardPage.tsx`
|
||||
- 配置: `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>'] // 访问所有网站
|
||||
```
|
||||
src/pages/FeatureName/
|
||||
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
├── constants.ts # 常量定义(可选,≥3 个常量时创建)
|
||||
└── __tests__/
|
||||
└── index.test.tsx
|
||||
```
|
||||
|
||||
#### CI/CD 配置
|
||||
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
|
||||
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||
- 子组件可以独立调用全局 Hook
|
||||
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
||||
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
||||
|
||||
- `.github/workflows/ci.yml` - 持续集成工作流
|
||||
- `.github/workflows/release.yml` - 发布工作流
|
||||
### 测试数据生成器模块
|
||||
|
||||
## 开发注意事项
|
||||
```
|
||||
src/pages/TestDataGenerator/
|
||||
├── index.tsx # 主页面(字段配置 + 标签页切换)
|
||||
├── hooks/useGenerator.ts # Web Worker 管理 Hook(创建、复用、通信、销毁)
|
||||
└── components/
|
||||
├── FieldList.tsx # 字段列表(虚拟滚动 + @dnd-kit 拖拽排序 + 规则保存)
|
||||
├── FieldItem.tsx # 字段卡片展示
|
||||
├── FieldEditor.tsx # 字段编辑器(名称校验、生成器选择、参数配置)
|
||||
├── GeneratorSelector.tsx # 生成器选择器(分类 + 搜索)
|
||||
├── GeneratorConfig.tsx # 生成器参数表单(动态渲染 string/number/boolean/select/array)
|
||||
├── GenerateOptions.tsx # 生成选项(数量、格式)
|
||||
├── GenerateButton.tsx # 生成按钮 + 进度条
|
||||
├── DataPreview.tsx # 示例数据预览(JSON 语法高亮)
|
||||
├── ResultPanel.tsx # 生成结果状态面板
|
||||
├── ExportPanel.tsx # 导出面板(复制/下载 JSON/CSV)
|
||||
└── RuleManager.tsx # 规则管理(CRUD、搜索、导入/导出)
|
||||
|
||||
### 扩展入口点
|
||||
src/utils/
|
||||
├── ruleStorage.ts # 规则持久化存储(localStorage)
|
||||
└── dataExporter.ts # 数据导出工具(JSON/CSV 转换、下载、剪贴板)
|
||||
|
||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
||||
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
|
||||
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
|
||||
src/lib/generators/ # 内置生成器定义(个人信息、企业、技术、基础类型)
|
||||
|
||||
### 路由系统
|
||||
src/workers/
|
||||
└── generator.worker.ts # 数据生成 Web Worker
|
||||
|
||||
- 使用自定义路由系统,支持多种显示模式
|
||||
- 路由配置在 `config/features.tsx`
|
||||
- 通过 `getEntryPointType()` 判断当前入口点类型
|
||||
- 支持页面可见性配置(`defaultVisible`)
|
||||
src/types/
|
||||
└── testDataGenerator.ts # 类型定义(FieldConfig, DataRule, GeneratorDefinition 等)
|
||||
```
|
||||
|
||||
### 浏览器兼容性
|
||||
## 关键架构决策
|
||||
|
||||
- 支持 Chrome 和 Firefox 浏览器
|
||||
- 使用 WXT 框架抽象浏览器差异
|
||||
- 使用 `@types/chrome` 和 `@types/webextension-polyfill` 提供类型支持
|
||||
**路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 tab(浏览器新标签页,TopBar「在标签页打开」调用 `openExtensionPage('popup.html', { mode: 'tab' })`,由 `getEntryPointType()` 根据 URL 参数 `mode=tab` 识别)。
|
||||
每种模式有独立的路由和可见页面配置(`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute` 等)。
|
||||
|
||||
### 代码质量
|
||||
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts` 的 `StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
|
||||
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 快照(`snapshot/{key}`)消除首屏闪烁。
|
||||
异步加载完成前禁止写入 storage(`RouterProvider` 的 `canPersistRef`、`useStorageState` 的 `loadSucceededRef`),避免默认值覆盖已有数据。
|
||||
|
||||
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint)
|
||||
- Prettier 进行代码格式化
|
||||
- Husky v9 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范
|
||||
- GitHub Actions CI/CD 自动化测试和构建
|
||||
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`。
|
||||
|
||||
### 测试策略
|
||||
**路径别名**: `@/` 映射到 `src/` 目录(已在 `.wxt/tsconfig.json` 和 `vitest.config.ts` 中配置)。项目根目录使用 `@@/`。
|
||||
|
||||
- 组件测试: `components/__tests__/` 目录
|
||||
- 工具函数测试: `utils/__tests__/` 目录
|
||||
- 配置测试: `config/__tests__/` 目录
|
||||
- 使用 Vitest 作为测试框架
|
||||
- 使用 Testing Library 进行 React 组件测试
|
||||
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 环境: jsdom
|
||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
||||
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||
|
||||
## UI 文案
|
||||
|
||||
项目已移除 `chrome.i18n`,UI 文案直接在代码中使用中文。
|
||||
|
||||
- **功能元数据**: `src/config/features.tsx` 的 `FEATURES` 数组定义 `label`、`description`(用于 Dashboard 卡片与搜索)
|
||||
- **页面文案**: 在组件 JSX、`constants.ts` 或 Hook 中直接写中文
|
||||
- **Manifest 文案**: 扩展名称与描述在 `wxt.config.ts` 的 `manifest` 中维护
|
||||
- **Toast / 错误提示**: 在 Hook 或 `constants.ts` 中定义,使用 `sonner` 的 `toast()` 展示
|
||||
|
||||
## 新功能开发清单
|
||||
|
||||
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
||||
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、label、description、图标、三种渲染模式的组件)
|
||||
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
||||
- `index.tsx` — UI 组件(纯展示)
|
||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||
- `constants.ts` — 常量定义(可选,≥3 个常量时创建)
|
||||
4. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||
5. 添加对应的单元测试
|
||||
|
||||
## 代码规范
|
||||
|
||||
- 禁止使用 `any` (测试文件除外)
|
||||
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
|
||||
- 图标: 使用 `lucide-react` 图标库
|
||||
- 格式: Prettier [配置](./.prettierrc)
|
||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`
|
||||
- Git Commit: 使用中文描述,遵循 [Conventional Commits 规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/)
|
||||
|
||||
## 关键外部库(非显而易见的)
|
||||
|
||||
- `@webext-core/messaging` — 扩展消息通信
|
||||
- `@dnd-kit` — 拖拽排序(用于页面顺序管理和字段列表排序)
|
||||
- `qrious` + `qr-scanner` — 二维码生成与解析
|
||||
- `dayjs` — 日期处理(时间戳转换)
|
||||
- `sonner` — Toast 通知
|
||||
|
||||
@@ -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,166 @@
|
||||
# Testing Tools Browser Extension
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能。
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具.
|
||||
|
||||
## 项目概述
|
||||
|
||||
Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
||||
**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持.
|
||||
|
||||
## 功能特性
|
||||
|
||||
### Dashboard 首页
|
||||
|
||||
- 卡片式工具展示
|
||||
- 支持自定义工具排序和可见性
|
||||
- 实时数据预览(时间戳等)
|
||||
- **工具导航**: 快速访问所有可用工具.
|
||||
- **个性化定制**: 支持自定义工具的排序和可见性.
|
||||
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
|
||||
|
||||
### 时间戳转换工具
|
||||
|
||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
||||
- 日期与时间戳之间的双向转换
|
||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
||||
- 一键复制转换结果
|
||||
- 输入验证和错误提示
|
||||
- **实时显示**: 毫秒级精度显示当前系统时间.
|
||||
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
|
||||
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
|
||||
- **快捷操作**: 一键复制转换结果,支持多种格式.
|
||||
|
||||
### 存储清理工具
|
||||
|
||||
- 自动读取当前域名
|
||||
- 支持清理多种存储类型:
|
||||
- localStorage
|
||||
- sessionStorage
|
||||
- IndexedDB
|
||||
- Cookies
|
||||
- Cache Storage
|
||||
- Service Workers
|
||||
- 可选择的清理类型(默认全选)
|
||||
- 确认对话框防止误操作
|
||||
- 清理结果统计
|
||||
- 自动刷新页面选项
|
||||
- **智能识别**: 自动检测并显示当前活动标签页的域名.
|
||||
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
|
||||
- **细粒度控制**: 可根据需要选择特定的清理项.
|
||||
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
|
||||
|
||||
### URL 工具
|
||||
### 文本统计工具
|
||||
|
||||
- 保存常用 URL 列表
|
||||
- 快速打开保存的 URL
|
||||
- 支持 URL 验证和安全检查
|
||||
- 内置 URL 查看器(iframe 沙箱模式)
|
||||
- **实时分析**: 键入即统计,无需额外操作.
|
||||
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
|
||||
- **性能优化**: 采用高性能分词算法,支持大文本处理.
|
||||
|
||||
### JWT 解析工具
|
||||
|
||||
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
|
||||
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
|
||||
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
||||
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
||||
|
||||
### 右键恢复工具
|
||||
|
||||
- **当前页面检测**: 自动识别当前活动标签页的域名.
|
||||
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
|
||||
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
|
||||
|
||||
### 二维码工具
|
||||
|
||||
- URL 转二维码(生成器)
|
||||
- 二维码转 URL(解析器)
|
||||
- 支持上传二维码图片解析
|
||||
- 生成的二维码可下载
|
||||
- 一键复制转换结果
|
||||
- 卡片式布局,节省空间
|
||||
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||
|
||||
### JSON 工具
|
||||
|
||||
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
|
||||
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
|
||||
|
||||
### Base64 转换器
|
||||
|
||||
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
|
||||
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||
|
||||
### 测试数据生成器
|
||||
|
||||
- **可视化字段配置**: 通过 UI 界面定义数据字段,支持拖拽排序、最多 40 个字段.
|
||||
- **丰富的内置生成器**: 涵盖个人信息(姓名、手机、邮箱)、企业数据(公司名、职位)、技术数据(IP、MAC 地址、UUID)、基础类型(数字、日期、枚举)等多个分类.
|
||||
- **灵活的参数配置**: 每个生成器支持自定义参数(如数字范围、日期格式、枚举值列表等).
|
||||
- **空值率与唯一性**: 可为非必填字段设置空值率,支持字段唯一性约束.
|
||||
- **规则管理**: 保存、加载、编辑、复制、导入/导出字段配置规则,方便复用.
|
||||
- **批量生成**: 支持 1 ~ 100,000 条数据生成,通过 Web Worker 异步处理避免阻塞 UI.
|
||||
- **实时预览**: 配置字段后即时预览示例数据结构.
|
||||
- **多格式导出**: 支持 JSON 和 CSV 格式,提供复制到剪贴板和下载文件两种导出方式.
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit)
|
||||
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI
|
||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||
- **UI 文案**: 组件内直接使用中文字符串;功能名称与描述定义在 `src/config/features.tsx`
|
||||
- **通信**: @webext-core/messaging
|
||||
- **存储**: Chrome Storage API (类型安全封装)
|
||||
- **二维码**: qrcode (生成) + jsqr (解析)
|
||||
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||
- **测试**: Vitest + Testing Library
|
||||
- **拖拽排序**: @dnd-kit/core + @dnd-kit/sortable
|
||||
- **异步生成**: Web Worker (批量数据生成)
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── components/ # 可复用 UI 组件
|
||||
│ ├── Button.tsx
|
||||
│ ├── CopyButton.tsx
|
||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
|
||||
│ ├── GlobalSnackbar.tsx
|
||||
│ ├── PageHeader.tsx # 页面标题栏组件
|
||||
│ ├── RouterContainer.tsx
|
||||
│ ├── StorageCleanerConfirm.tsx
|
||||
│ ├── ToolCard.tsx
|
||||
│ └── TopBar.tsx
|
||||
├── config/ # 配置文件
|
||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
|
||||
│ └── routes.ts # 页面路由定义
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── popup/ # 扩展弹窗界面
|
||||
│ │ ├── App.tsx
|
||||
│ │ ├── main.tsx
|
||||
│ │ └── pages/ # 页面组件
|
||||
│ │ ├── DashboardPage.tsx
|
||||
│ │ ├── 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
|
||||
```text
|
||||
├── src/ # 源代码根目录
|
||||
│ ├── components/ # 可复用 React 组件
|
||||
│ ├── config/ # 应用配置
|
||||
│ │ └── features.tsx # 功能定义与路由映射
|
||||
│ ├── entrypoints/ # 扩展程序入口点
|
||||
│ │ ├── popup/ # 点击图标弹出的主界面
|
||||
│ │ ├── sidepanel/ # 浏览器侧边栏集成
|
||||
│ │ ├── background.ts # 后台 Service Worker
|
||||
│ │ └── content.ts # 网页注入脚本
|
||||
│ ├── pages/ # 各功能模块的页面组件
|
||||
│ ├── workers/ # Web Worker (数据生成等耗时任务)
|
||||
│ ├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||
│ ├── hooks/ # 自定义 React Hooks
|
||||
│ ├── utils/ # 工具函数与服务抽象
|
||||
│ ├── types/ # TypeScript 类型声明
|
||||
│ └── lib/ # 通用工具函数与生成器库 (cn, utils, generators 等)
|
||||
├── public/ # 静态资源 (图标等)
|
||||
├── wxt.config.ts # WXT 框架核心配置
|
||||
└── package.json # 项目元数据与依赖管理
|
||||
```
|
||||
|
||||
## 路由系统
|
||||
## 开发与部署
|
||||
|
||||
项目实现了灵活的路由系统,支持:
|
||||
### 开发环境要求
|
||||
|
||||
- **页面导航**: 在不同工具页面之间切换
|
||||
- **路由同步**: 通过 Chrome Storage 同步路由状态
|
||||
- **可见性控制**: 可配置显示哪些页面
|
||||
- **页面排序**: 自定义工具卡片的显示顺序
|
||||
- Node.js >= 18.x
|
||||
- npm 或 pnpm
|
||||
|
||||
### 页面类型 (PageType)
|
||||
### 常用命令
|
||||
|
||||
| 页面 | 说明 | 默认可见 |
|
||||
| ---------------- | ---------- | -------- |
|
||||
| `dashboard` | 首页 | ✓ |
|
||||
| `timestamp` | 时间戳转换 | ✓ |
|
||||
| `storageCleaner` | 存储清理 | ✓ |
|
||||
| `openUrl` | URL 工具 | ✓ |
|
||||
| `qrCode` | 二维码工具 | ✓ |
|
||||
| `openUrlViewer` | URL 查看器 | ✗ |
|
||||
| 命令 | 说明 |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||
| `npm run build` | 构建 Chrome 生产版本 |
|
||||
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||
| `npm run test` | 运行单元测试 |
|
||||
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||
|
||||
## 扩展入口点
|
||||
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||
|
||||
| 入口点 | 说明 |
|
||||
| -------------- | ------------------------ |
|
||||
| **popup** | 点击扩展图标弹出的界面 |
|
||||
| **options** | 扩展选项页面 |
|
||||
| **sidepanel** | 浏览器侧边栏 |
|
||||
| **background** | 后台脚本(生命周期管理) |
|
||||
| **content** | 内容脚本(注入到网页) |
|
||||
### 自动化流程
|
||||
|
||||
## 开发环境要求
|
||||
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||
|
||||
- Node.js >= 18
|
||||
- npm 或 yarn
|
||||
|
||||
## 安装与运行
|
||||
|
||||
### 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` — 自动发布
|
||||
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||
|
||||
## 权限说明
|
||||
|
||||
扩展请求以下权限:
|
||||
本扩展根据功能需要申请了以下权限:
|
||||
|
||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
||||
- `cookies` - Cookie 访问
|
||||
- `sidePanel` - 侧边栏支持
|
||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
||||
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||
- `cookies`: 管理和清理网站 Cookie.
|
||||
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||
- `clipboardWrite`: 提供一键复制功能.
|
||||
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||
|
||||
## 主要依赖
|
||||
## 浏览器支持
|
||||
|
||||
- `react`, `react-dom` - 前端框架
|
||||
- `@mui/material` - UI 组件库
|
||||
- `dayjs` - 日期处理
|
||||
- `@webext-core/messaging` - 扩展消息通信
|
||||
- `vitest` - 测试框架
|
||||
- `@testing-library/react` - React 组件测试
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- Chrome (推荐)
|
||||
- Chrome (及其它 Chromium 内核浏览器)
|
||||
- Firefox
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
||||
基于 [MIT License](LICENSE) 开源.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/shell.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;
|
||||
@@ -1,88 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
|
||||
/**
|
||||
* 复制按钮组件属性
|
||||
* @param text 要复制的文本
|
||||
* @param tooltip 提示信息
|
||||
* @param size 按钮大小
|
||||
* @param color 按钮颜色
|
||||
* @param style 自定义样式
|
||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
||||
*/
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
|
||||
style?: React.CSSProperties;
|
||||
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> = ({
|
||||
text,
|
||||
tooltip = '复制',
|
||||
size = 'small',
|
||||
color = 'primary',
|
||||
style,
|
||||
showMessage,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (text) {
|
||||
const success = await copyToClipboard(text, showMessage);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
} else {
|
||||
showMessage?.('无内容可复制', { severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<IconButton
|
||||
size={size}
|
||||
onClick={handleCopy}
|
||||
style={style}
|
||||
sx={{
|
||||
color: copied ? 'success.main' : color,
|
||||
bgcolor: '#fff',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
||||
'&:hover': {
|
||||
bgcolor: copied
|
||||
? 'success.main'
|
||||
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
||||
? color
|
||||
: `${color}.main`,
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
||||
) : (
|
||||
<ContentCopyIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -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;
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Box, Typography, Button, Paper, Container } from '@mui/material';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Container sx={{ mt: 8 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 4,
|
||||
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>
|
||||
{this.state.error && (
|
||||
<Box
|
||||
sx={{
|
||||
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()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReset}
|
||||
sx={{ borderRadius: 2, fontWeight: 700 }}
|
||||
>
|
||||
刷新应用
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -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;
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件
|
||||
*
|
||||
* 提供可复用的 Toast 消息提示功能,支持两种使用方式:
|
||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
|
||||
*
|
||||
* @module GlobalSnackbar
|
||||
* @version 1.0.0
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 方式一:受控组件
|
||||
* <GlobalSnackbar
|
||||
* message="操作成功"
|
||||
* open={isOpen}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* severity="success"
|
||||
* />
|
||||
*
|
||||
* // 方式二:Hook 方式
|
||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||
* showMessage('Hello!', { severity: 'info' });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { JSX, useState } from 'react';
|
||||
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
|
||||
|
||||
/**
|
||||
* Snackbar 消息严重程度类型
|
||||
* @description 决定 Alert 组件的颜色和图标
|
||||
* - success: 绿色,成功提示
|
||||
* - info: 蓝色,信息提示
|
||||
* - warning: 橙色,警告提示
|
||||
* - error: 红色,错误提示
|
||||
*/
|
||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
/**
|
||||
* 重新导出 SnackbarProvider 组件
|
||||
* @description 提供 Context 方式的全局 Snackbar 功能
|
||||
*/
|
||||
export { SnackbarProvider } from './SnackbarProvider';
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的属性接口
|
||||
* @interface GlobalSnackbarProps
|
||||
*/
|
||||
export interface GlobalSnackbarProps {
|
||||
/** 消息内容,要显示的提示文本 */
|
||||
message: string;
|
||||
/** 是否显示 Snackbar */
|
||||
open: boolean;
|
||||
/** 关闭回调函数 */
|
||||
onClose: () => void;
|
||||
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
||||
severity?: SnackbarSeverity;
|
||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
||||
autoHideDuration?: number;
|
||||
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
||||
anchorOrigin?: {
|
||||
vertical: 'top' | 'bottom';
|
||||
horizontal: 'left' | 'center' | 'right';
|
||||
};
|
||||
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
||||
showAlert?: boolean;
|
||||
/** 是否隐藏 Alert 图标,默认 false */
|
||||
hideIcon?: boolean;
|
||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
||||
sx?: SxProps<Theme>;
|
||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
||||
alertSx?: SxProps<Theme>;
|
||||
}
|
||||
|
||||
/**
|
||||
* showMessage 方法的选项配置
|
||||
* @interface SnackbarOptions
|
||||
*/
|
||||
export interface SnackbarOptions {
|
||||
/** 消息级别:success | info | warning | error */
|
||||
severity?: SnackbarSeverity;
|
||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
||||
autoHideDuration?: number;
|
||||
/** 是否隐藏 Alert 图标 */
|
||||
hideIcon?: boolean;
|
||||
/** 是否使用 Alert 组件包裹 */
|
||||
showAlert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbarState Hook 的返回值类型
|
||||
* @interface UseSnackbarStateResult
|
||||
*/
|
||||
export interface UseSnackbarStateResult {
|
||||
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
||||
snackbarProps: GlobalSnackbarProps;
|
||||
/** 显示消息的方法 */
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
/** 关闭消息的方法 */
|
||||
closeMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的默认属性配置
|
||||
* @description 提供类型安全的默认值选择
|
||||
*/
|
||||
const defaultProps: Required<
|
||||
Pick<
|
||||
GlobalSnackbarProps,
|
||||
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
||||
>
|
||||
> = {
|
||||
severity: 'info',
|
||||
autoHideDuration: 2000,
|
||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||
showAlert: true,
|
||||
hideIcon: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件
|
||||
*
|
||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
||||
* 使用 MUI Snackbar 和 Alert 组件实现消息提示功能。
|
||||
*
|
||||
* @param {GlobalSnackbarProps} props - 组件属性
|
||||
* @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({
|
||||
message,
|
||||
open,
|
||||
onClose,
|
||||
severity = defaultProps.severity,
|
||||
autoHideDuration = defaultProps.autoHideDuration,
|
||||
showAlert = defaultProps.showAlert,
|
||||
hideIcon = defaultProps.hideIcon,
|
||||
}: GlobalSnackbarProps): JSX.Element {
|
||||
/**
|
||||
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
|
||||
*
|
||||
* @description
|
||||
* Portal 的优势:
|
||||
* - 避免父容器 overflow、z-index 等样式影响
|
||||
* - 确保 Snackbar 始终显示在最顶层
|
||||
* - 避免与其他组件的样式冲突
|
||||
*/
|
||||
return (
|
||||
<Portal>
|
||||
<Snackbar
|
||||
open={open}
|
||||
autoHideDuration={autoHideDuration}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
disableWindowBlurListener
|
||||
sx={{
|
||||
zIndex: 999999,
|
||||
// 确保距离底部的间距,响应式设计适配不同屏幕
|
||||
bottom: { xs: '24px', sm: '24px' },
|
||||
// 固定宽度时使用 transform 实现真正的居中
|
||||
left: '50%',
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbarState - 消息提示的 Hook 方式
|
||||
*
|
||||
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
||||
* 适合在组件内部使用,无需额外的状态管理代码。
|
||||
*
|
||||
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
||||
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
||||
*
|
||||
* @description
|
||||
* - 自动管理 Snackbar 的显示/隐藏状态
|
||||
* - 支持链式调用 showMessage
|
||||
* - 合并初始选项和调用时选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
||||
* severity: 'info',
|
||||
* autoHideDuration: 3000,
|
||||
* });
|
||||
*
|
||||
* const handleSave = () => {
|
||||
* // 业务逻辑...
|
||||
* showMessage('保存成功!', { severity: 'success' });
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button onClick={handleSave}>保存</button>
|
||||
* <GlobalSnackbar {...snackbarProps} />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
||||
// Snackbar 显示状态
|
||||
const [open, setOpen] = useState(false);
|
||||
// 当前显示的消息内容
|
||||
const [message, setMessage] = useState('');
|
||||
// 消息配置选项
|
||||
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
||||
|
||||
/**
|
||||
* 显示消息
|
||||
*
|
||||
* @param {string} newMessage - 要显示的消息文本
|
||||
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
||||
*
|
||||
* @description
|
||||
* - 合并初始选项和新的调用选项
|
||||
* - 新选项会覆盖初始选项
|
||||
*/
|
||||
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
||||
setMessage(newMessage);
|
||||
setOptions({ ...initialOptions, ...newOptions });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭消息
|
||||
*
|
||||
* @description
|
||||
* - 直接将 open 状态设置为 false
|
||||
*/
|
||||
const closeMessage = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 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;
|
||||
closeMessage();
|
||||
};
|
||||
|
||||
/**
|
||||
* 传递给 GlobalSnackbar 组件的属性
|
||||
*
|
||||
* @description
|
||||
* - 组合当前状态和选项为完整的组件 props
|
||||
* - onClose 使用 handleClose 包装后的版本
|
||||
*/
|
||||
const snackbarProps: GlobalSnackbarProps = {
|
||||
message,
|
||||
open,
|
||||
onClose: handleClose,
|
||||
severity: options.severity,
|
||||
autoHideDuration: options.autoHideDuration,
|
||||
hideIcon: options.hideIcon,
|
||||
};
|
||||
|
||||
return {
|
||||
snackbarProps,
|
||||
showMessage,
|
||||
closeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的默认导出
|
||||
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
|
||||
*/
|
||||
export default GlobalSnackbar;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,40 +0,0 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function RouterContainer() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
|
||||
const animationClass = useMemo(() => {
|
||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||
}, [currentPage]);
|
||||
|
||||
const entryPointType = useMemo(() => {
|
||||
return getEntryPointType();
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return <div className="app">Loading...</div>;
|
||||
}
|
||||
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
const Component = currentFeature ? currentFeature.components[entryPointType] : null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={currentPage} // Trigger animation on navigation
|
||||
className={animationClass}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
scrollbarGutter: 'stable',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{Component && <Component />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
|
||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||
const { currentPage, goBack } = useRouter();
|
||||
|
||||
const isDetachedMode = useMemo(() => {
|
||||
return new URLSearchParams(window.location.search).get('mode') === 'detached';
|
||||
}, []);
|
||||
|
||||
const handleDetach = () => {
|
||||
// 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('mode', 'detached');
|
||||
|
||||
chrome.windows.create({
|
||||
url: url.toString(),
|
||||
type: 'panel',
|
||||
width: 420,
|
||||
height: 600
|
||||
});
|
||||
};
|
||||
|
||||
const isDashboard = currentPage === 'dashboard';
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
bgcolor: 'background.paper',
|
||||
zIndex: 1100
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40 }}>
|
||||
{!isDashboard && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={goBack}
|
||||
sx={{
|
||||
bgcolor: 'grey.50',
|
||||
'&:hover': { bgcolor: 'grey.200' }
|
||||
}}
|
||||
>
|
||||
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Testing Tools
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
|
||||
{!isDetachedMode && (
|
||||
<Tooltip title="独立窗口模式">
|
||||
<IconButton size="small" onClick={handleDetach}>
|
||||
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="设置">
|
||||
<IconButton size="small" onClick={onOpenOptions}>
|
||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, act, renderHook } from '@testing-library/react';
|
||||
import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
|
||||
|
||||
describe('GlobalSnackbar 组件系统', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const defaultProps: GlobalSnackbarProps = {
|
||||
message: '测试消息',
|
||||
open: true,
|
||||
onClose: mockOnClose,
|
||||
};
|
||||
|
||||
describe('GlobalSnackbar UI 渲染', () => {
|
||||
it('应渲染消息内容并由于使用了 Portal 出现在 body 中', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} />);
|
||||
// 因为使用了 Portal,它不在常规 render 的容器内,但在 document 中
|
||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 showAlert 为 true 时应渲染 MUI Alert 样式', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
||||
// 验证是否包含 MUI Alert 的类名
|
||||
const alertElement = document.querySelector('.MuiAlert-root');
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
expect(alertElement).toHaveTextContent('测试消息');
|
||||
});
|
||||
|
||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
||||
// MUI Alert 图标通常在 .MuiAlert-icon 中
|
||||
const icon = document.querySelector('.MuiAlert-icon');
|
||||
expect(icon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应根据 severity 应用不同的样式 (通过检查 style 或 class)', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
||||
const alert = document.querySelector('.MuiAlert-filledError');
|
||||
expect(alert).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSnackbarState Hook 逻辑', () => {
|
||||
it('应能正确初始化并更新状态', () => {
|
||||
const { result } = renderHook(() => useSnackbarState({ severity: 'warning' }));
|
||||
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
|
||||
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', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
|
||||
act(() => {
|
||||
result.current.showMessage('测试');
|
||||
});
|
||||
expect(result.current.snackbarProps.open).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.closeMessage();
|
||||
});
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互与自动隐藏', () => {
|
||||
it('在 autoHideDuration 结束后应触发 onClose', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} autoHideDuration={3000} />);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('当 reason 为 clickaway 时不应调用 onClose (源码逻辑验证)', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
|
||||
// 模拟 MUI 的 handleClose 被 clickaway 触发
|
||||
act(() => {
|
||||
result.current.snackbarProps.onClose();
|
||||
});
|
||||
|
||||
// 状态应该保持 open: true
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
// 注意:此处取决于你对 useSnackbarState 的期望。
|
||||
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import RouterContainer from '../RouterContainer';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { SnackbarProvider } from '@/components/SnackbarProvider';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
navigateLocal: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('RouterContainer 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<SnackbarProvider>
|
||||
<RouterProvider>{ui}</RouterProvider>
|
||||
</SnackbarProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('isLoaded 为 false 时应渲染加载状态', () => {
|
||||
mockRouterValue.isLoaded = false;
|
||||
renderWithProvider(<RouterContainer />);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('动画类测试', () => {
|
||||
it('在 dashboard 页面应应用 dashboard 动画类', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<RouterContainer />);
|
||||
const box = document.querySelector('.page-transition-dashboard');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在非 dashboard 页面应应用 enter 动画类', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<RouterContainer />);
|
||||
const box = document.querySelector('.page-transition-enter');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('路由处理测试', () => {
|
||||
it('currentPage 变化时应更新', () => {
|
||||
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
rerender(
|
||||
<SnackbarProvider>
|
||||
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
||||
</SnackbarProvider>,
|
||||
);
|
||||
|
||||
const box = document.querySelector('.page-transition-enter');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ToolCard from '../ToolCard';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
|
||||
describe('ToolCard 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题和描述', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="测试工具"
|
||||
description="这是一个测试工具"
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
||||
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无描述时仅渲染标题', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="仅标题"
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染图标', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="带图标"
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon data-testid="test-icon" />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('提供快照内容时应渲染快照', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="带快照"
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={() => {}}
|
||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('未提供快照时不渲染快照区域', () => {
|
||||
const { container } = render(
|
||||
<ToolCard
|
||||
title="无快照"
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI 徽章测试', () => {
|
||||
it('hasAI 为 true 时应渲染 AI 徽章', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="AI 工具"
|
||||
hasAI={true}
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
|
||||
expect(autoAwesomeIcon).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('交互测试', () => {
|
||||
it('点击时应调用 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<ToolCard
|
||||
title="可点击"
|
||||
colorCode="#2196f3"
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={handleClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByText('可点击').closest('.MuiBox-root');
|
||||
if (card) {
|
||||
fireEvent.click(card);
|
||||
}
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('应应用自定义颜色代码', () => {
|
||||
const customColor = '#ff5722';
|
||||
const { container } = render(
|
||||
<ToolCard
|
||||
title="自定义颜色"
|
||||
colorCode={customColor}
|
||||
icon={<AccessTimeIcon />}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const iconContainer = container.querySelector('.MuiBox-root > div');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import TopBar from '../TopBar';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import type { PageType } from '@/types/storage';
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
navigateLocal: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('TopBar 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(<RouterProvider>{ui}</RouterProvider>);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应使用默认标题渲染', () => {
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByText('Testing Tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染设置按钮', () => {
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击设置按钮时应调用 onOpenOptions', () => {
|
||||
const handleOpenOptions = vi.fn();
|
||||
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('SettingsIcon'));
|
||||
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击返回按钮时应调用 goBack', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon'));
|
||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
FEATURES,
|
||||
getFeatureByKey,
|
||||
getDefaultVisibleFeatureKeys,
|
||||
getAllFeatureKeys,
|
||||
getDefaultPageOrder,
|
||||
} from '../features';
|
||||
|
||||
describe('features', () => {
|
||||
describe('FEATURES', () => {
|
||||
it('should have 9 features defined', () => {
|
||||
expect(FEATURES).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should have all required properties for each feature', () => {
|
||||
FEATURES.forEach((feature) => {
|
||||
expect(feature).toHaveProperty('key');
|
||||
expect(feature).toHaveProperty('label');
|
||||
expect(feature).toHaveProperty('description');
|
||||
expect(feature).toHaveProperty('defaultVisible');
|
||||
expect(feature).toHaveProperty('components');
|
||||
expect(typeof feature.key).toBe('string');
|
||||
expect(typeof feature.label).toBe('string');
|
||||
expect(typeof feature.description).toBe('string');
|
||||
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||
expect(typeof feature.components).toBe('object');
|
||||
expect(feature.components).toHaveProperty('popup');
|
||||
expect(feature.components).toHaveProperty('sidepanel');
|
||||
expect(feature.components).toHaveProperty('detached');
|
||||
|
||||
// Optional UI properties for non-hidden features
|
||||
if (feature.key !== 'dashboard' && feature.key !== 'openUrlViewer') {
|
||||
expect(feature).toHaveProperty('icon');
|
||||
expect(feature).toHaveProperty('themeColor');
|
||||
expect(typeof feature.themeColor).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique keys for each feature', () => {
|
||||
const keys = FEATURES.map((f) => f.key);
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatureByKey', () => {
|
||||
it('should return dashboard feature', () => {
|
||||
const feature = getFeatureByKey('dashboard');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('dashboard');
|
||||
expect(feature?.label).toBe('Dashboard');
|
||||
});
|
||||
|
||||
it('should return timestamp feature', () => {
|
||||
const feature = getFeatureByKey('timestamp');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('timestamp');
|
||||
expect(feature?.label).toBe('时间戳');
|
||||
expect(feature?.themeColor).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return storageCleaner feature', () => {
|
||||
const feature = getFeatureByKey('storageCleaner');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('storageCleaner');
|
||||
expect(feature?.label).toBe('存储清理');
|
||||
});
|
||||
|
||||
it('should return undefined for invalid key', () => {
|
||||
const feature = getFeatureByKey('invalid' as any);
|
||||
expect(feature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultVisibleFeatureKeys', () => {
|
||||
it('should return only visible features', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
visibleKeys.forEach((key) => {
|
||||
const feature = getFeatureByKey(key);
|
||||
expect(feature?.defaultVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include dashboard, timestamp, storageCleaner, openUrl', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
expect(visibleKeys).toContain('dashboard');
|
||||
expect(visibleKeys).toContain('timestamp');
|
||||
expect(visibleKeys).toContain('storageCleaner');
|
||||
expect(visibleKeys).toContain('openUrl');
|
||||
});
|
||||
|
||||
it('should not include openUrlViewer (not visible by default)', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
expect(visibleKeys).not.toContain('openUrlViewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFeatureKeys', () => {
|
||||
it('should return all feature keys', () => {
|
||||
const allKeys = getAllFeatureKeys();
|
||||
expect(allKeys).toHaveLength(9);
|
||||
expect(allKeys).toContain('dashboard');
|
||||
expect(allKeys).toContain('timestamp');
|
||||
expect(allKeys).toContain('storageCleaner');
|
||||
expect(allKeys).toContain('openUrl');
|
||||
expect(allKeys).toContain('qrCode');
|
||||
expect(allKeys).toContain('formRecognizer');
|
||||
expect(allKeys).toContain('openUrlViewer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultPageOrder', () => {
|
||||
it('should exclude dashboard from page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).not.toContain('dashboard');
|
||||
});
|
||||
|
||||
it('should exclude openUrlViewer from page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).not.toContain('openUrlViewer');
|
||||
});
|
||||
|
||||
it('should include timestamp, storageCleaner, openUrl, qrCode, formRecognizer in page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).toContain('timestamp');
|
||||
expect(pageOrder).toContain('storageCleaner');
|
||||
expect(pageOrder).toContain('openUrl');
|
||||
expect(pageOrder).toContain('qrCode');
|
||||
expect(pageOrder).toContain('formRecognizer');
|
||||
});
|
||||
|
||||
it('should have 7 items in page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
|
||||
import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 功能配置接口
|
||||
*
|
||||
* 整合了路由信息和仪表盘卡片元数据,作为功能的单一事实来源
|
||||
*/
|
||||
export interface FeatureConfig {
|
||||
/** 页面类型标识 */
|
||||
key: PageType;
|
||||
/** 功能名称(用于路由标签和卡片标题) */
|
||||
label: string;
|
||||
/** 功能描述(用于仪表盘卡片) */
|
||||
description: string;
|
||||
/** 主题颜色(用于仪表盘卡片) */
|
||||
themeColor?: string;
|
||||
/** 图标组件(用于仪表盘卡片) */
|
||||
icon?: ReactNode;
|
||||
/** 默认是否在仪表盘显示 */
|
||||
defaultVisible: boolean;
|
||||
/** 不同显示模式对应的组件 */
|
||||
components: {
|
||||
/** 弹窗模式组件 */
|
||||
popup: React.ComponentType;
|
||||
/** 侧边栏模式组件 */
|
||||
sidepanel: React.ComponentType;
|
||||
/** 独立窗口模式组件 */
|
||||
detached: React.ComponentType;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEATURES: FeatureConfig[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
description: '',
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: DashboardPage,
|
||||
sidepanel: DashboardPage,
|
||||
detached: DashboardPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: '时间戳',
|
||||
description: 'Unix 毫秒数转换与格式化',
|
||||
themeColor: THEME_COLORS.primary,
|
||||
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: TimestampPage,
|
||||
sidepanel: TimestampPage,
|
||||
detached: TimestampPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'storageCleaner',
|
||||
label: '存储清理',
|
||||
description: '清理缓存、Cookies 及本地存储',
|
||||
themeColor: THEME_COLORS.warning,
|
||||
icon: <StorageIcon sx={{ fontSize: 20 }} />,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: StorageCleanerPage,
|
||||
sidepanel: StorageCleanerPage,
|
||||
detached: 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',
|
||||
label: '二维码工具',
|
||||
description: '生成当前选中的 URL 的二维码',
|
||||
themeColor: THEME_COLORS.success,
|
||||
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: QrCodePage,
|
||||
sidepanel: QrCodePage,
|
||||
detached: QrCodePage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'formMapping',
|
||||
label: '表单映射',
|
||||
description: '智能识别表单指纹,自定义填充逻辑',
|
||||
themeColor: THEME_COLORS.primary,
|
||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: FormMappingPage,
|
||||
sidepanel: FormMappingPage,
|
||||
detached: FormMappingPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'formFill',
|
||||
label: '智能填充',
|
||||
description: '根据表单指纹填充表单数据',
|
||||
themeColor: THEME_COLORS.primary,
|
||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: FormFillPage,
|
||||
sidepanel: FormFillPage,
|
||||
detached: FormFillPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'formRecognizer',
|
||||
label: '表单识别',
|
||||
description: '智能识别表单指纹',
|
||||
themeColor: THEME_COLORS.primary,
|
||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: FormRecognizerPage,
|
||||
sidepanel: FormRecognizerPage,
|
||||
detached: FormRecognizerPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'openUrlViewer',
|
||||
label: '查看',
|
||||
description: '',
|
||||
defaultVisible: false,
|
||||
components: {
|
||||
popup: OpenUrlViewerPage,
|
||||
sidepanel: OpenUrlViewerPage,
|
||||
detached: OpenUrlViewerPage,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||
return FEATURES.find((f) => f.key === key);
|
||||
}
|
||||
|
||||
export function getDefaultVisibleFeatureKeys(): PageType[] {
|
||||
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
|
||||
}
|
||||
|
||||
export function getAllFeatureKeys(): PageType[] {
|
||||
return FEATURES.map((f) => f.key);
|
||||
}
|
||||
|
||||
export function getDefaultPageOrder(): PageType[] {
|
||||
return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map(
|
||||
(f) => f.key,
|
||||
);
|
||||
}
|
||||
|
||||
export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' {
|
||||
const pathname = window.location.pathname;
|
||||
if (pathname.includes('sidepanel')) {
|
||||
return 'sidepanel';
|
||||
}
|
||||
if (new URLSearchParams(window.location.search).get('mode') === 'detached') {
|
||||
return 'detached';
|
||||
}
|
||||
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;
|
||||
@@ -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,868 @@
|
||||
# Testing Tools — 视觉规范文档
|
||||
|
||||
> **版本**: 1.0.0
|
||||
> **日期**: 2026-05-29
|
||||
> **适用范围**: 所有新页面、新组件、UI 修改
|
||||
> **设计系统**: 基于 [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [设计原则](#1-设计原则)
|
||||
2. [色彩系统](#2-色彩系统)
|
||||
3. [排版规范](#3-排版规范)
|
||||
4. [间距与布局](#4-间距与布局)
|
||||
5. [圆角与阴影](#5-圆角与阴影)
|
||||
6. [组件规范](#6-组件规范)
|
||||
7. [交互与动效](#7-交互与动效)
|
||||
8. [暗色模式](#8-暗色模式)
|
||||
9. [工具色彩标识](#9-工具色彩标识)
|
||||
10. [代码规范](#10-代码规范)
|
||||
11. [反模式清单](#11-反模式清单)
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计原则
|
||||
|
||||
### 1.1 核心定位
|
||||
|
||||
Testing Tools 是一款**浏览器扩展开发者工具集**,视觉风格遵循:
|
||||
|
||||
- **专业克制** — 低饱和度色彩,避免视觉噪音
|
||||
- **信息密度优先** — 紧凑布局,在 400×600px 的 popup 空间内高效展示
|
||||
- **开发者友好** — 等宽字体用于代码/数据,清晰的信息层级
|
||||
- **一致性至上** — 所有页面、组件遵循同一套视觉语言
|
||||
|
||||
### 1.2 设计关键词
|
||||
|
||||
```
|
||||
简洁 · 现代 · 功能导向 · 低对比度 · 微圆角 · 微妙阴影
|
||||
```
|
||||
|
||||
### 1.3 与 shadcn/ui 的关系
|
||||
|
||||
本项目以 shadcn/ui 为底座,所有基础组件(Button、Input、Select 等)均来自或对齐 shadcn/ui 的默认样式。业务组件在此基础上扩展,**不得破坏底层设计语言的统一性**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 色彩系统
|
||||
|
||||
### 2.1 CSS 变量定义
|
||||
|
||||
所有色彩通过 CSS 自定义属性(HSL 格式)管理,定义于 `src/index.css`:
|
||||
|
||||
#### 亮色模式 (`:root`)
|
||||
|
||||
| 变量名 | HSL 值 | 用途 | 近似色 |
|
||||
| -------------------------- | ------------------- | ------------- | --------- |
|
||||
| `--background` | `0 0% 100%` | 页面背景 | `#ffffff` |
|
||||
| `--foreground` | `222.2 84% 4.9%` | 主文字 | `#020617` |
|
||||
| `--card` | `0 0% 100%` | 卡片背景 | `#ffffff` |
|
||||
| `--card-foreground` | `222.2 84% 4.9%` | 卡片文字 | `#020617` |
|
||||
| `--popover` | `0 0% 100%` | 浮层背景 | `#ffffff` |
|
||||
| `--popover-foreground` | `222.2 84% 4.9%` | 浮层文字 | `#020617` |
|
||||
| `--primary` | `222.2 47.4% 11.2%` | 主按钮/强调 | `#0f172a` |
|
||||
| `--primary-foreground` | `210 40% 98%` | 主按钮文字 | `#f8fafc` |
|
||||
| `--secondary` | `210 40% 96.1%` | 次级背景 | `#f1f5f9` |
|
||||
| `--secondary-foreground` | `222.2 47.4% 11.2%` | 次级文字 | `#0f172a` |
|
||||
| `--muted` | `210 40% 96.1%` | 静音/禁用背景 | `#f1f5f9` |
|
||||
| `--muted-foreground` | `215.4 16.3% 46.9%` | 次要文字 | `#64748b` |
|
||||
| `--accent` | `210 40% 96.1%` | 悬停高亮 | `#f1f5f9` |
|
||||
| `--accent-foreground` | `222.2 47.4% 11.2%` | 悬停文字 | `#0f172a` |
|
||||
| `--destructive` | `0 84.2% 60.2%` | 错误/删除 | `#ef4444` |
|
||||
| `--destructive-foreground` | `210 40% 98%` | 错误文字 | `#f8fafc` |
|
||||
| `--border` | `214.3 31.8% 91.4%` | 边框 | `#e2e8f0` |
|
||||
| `--input` | `214.3 31.8% 91.4%` | 输入框边框 | `#e2e8f0` |
|
||||
| `--ring` | `222.2 84% 4.9%` | 焦点环 | `#020617` |
|
||||
| `--radius` | `0.5rem` | 全局圆角 | `8px` |
|
||||
|
||||
#### 暗色模式 (`.dark`)
|
||||
|
||||
暗色模式下所有变量自动反转,保持对比度关系:
|
||||
|
||||
| 变量名 | HSL 值 | 近似色 |
|
||||
| ---------------------- | ------------------- | --------- |
|
||||
| `--background` | `222.2 84% 4.9%` | `#020617` |
|
||||
| `--foreground` | `210 40% 98%` | `#f8fafc` |
|
||||
| `--primary` | `210 40% 98%` | `#f8fafc` |
|
||||
| `--primary-foreground` | `222.2 47.4% 11.2%` | `#0f172a` |
|
||||
| `--secondary` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||
| `--muted` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||
| `--border` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||
|
||||
### 2.2 使用规范
|
||||
|
||||
```tsx
|
||||
// ✅ 正确:使用 CSS 变量
|
||||
<div className="bg-background text-foreground border-border">
|
||||
|
||||
// ✅ 正确:使用语义化色彩名
|
||||
<Button className="bg-primary text-primary-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
// ❌ 错误:硬编码颜色值
|
||||
<div className="bg-white text-black">
|
||||
<div className="bg-[#f1f5f9]">
|
||||
```
|
||||
|
||||
### 2.3 语义化色彩使用场景
|
||||
|
||||
| 色彩 | 场景 |
|
||||
| -------------------------- | -------------------------------- |
|
||||
| `background` | 页面根背景 |
|
||||
| `foreground` | 主标题、正文 |
|
||||
| `muted-foreground` | 描述文字、占位符、次级标签 |
|
||||
| `border` | 卡片边框、分割线、输入框边框 |
|
||||
| `card` + `card-foreground` | 卡片容器及其内容 |
|
||||
| `primary` | 主按钮、选中状态、关键操作 |
|
||||
| `secondary` | 次级按钮、工具栏背景、标签页背景 |
|
||||
| `destructive` | 错误提示、删除操作、验证失败 |
|
||||
| `accent` | 悬停背景、下拉选中项 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 排版规范
|
||||
|
||||
### 3.1 字体栈
|
||||
|
||||
项目使用系统默认字体栈(Tailwind 默认),**不引入自定义字体**。
|
||||
|
||||
```css
|
||||
/* Tailwind 默认 sans-serif */
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Arial,
|
||||
sans-serif;
|
||||
|
||||
/* 等宽字体用于代码/数据 */
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
```
|
||||
|
||||
### 3.2 字号层级
|
||||
|
||||
| 层级 | 类名 | 大小 | 字重 | 用途 |
|
||||
| --------- | ------------------------------------------------ | ---- | ------- | ---------------------- |
|
||||
| 页面标题 | `text-base font-bold` | 16px | 700 | 页面主标题(极少使用) |
|
||||
| 卡片标题 | `text-sm font-bold tracking-tight` | 14px | 700 | 卡片/区块标题 |
|
||||
| 正文 | `text-sm` | 14px | 400 | 普通正文 |
|
||||
| 次级文字 | `text-xs` | 12px | 400/500 | 描述、标签 |
|
||||
| 微标签 | `text-[10px] font-bold uppercase tracking-wider` | 10px | 700 | 区域标签、分类标题 |
|
||||
| 数据/代码 | `font-mono text-sm` | 14px | 400 | 时间戳、JSON、代码 |
|
||||
|
||||
### 3.3 排版模式
|
||||
|
||||
```tsx
|
||||
// 区域标签(Section Label)— 最常用
|
||||
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
||||
输出结果
|
||||
</span>
|
||||
|
||||
// 卡片标题
|
||||
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
|
||||
标题文字
|
||||
</h4>
|
||||
|
||||
// 描述文字
|
||||
<p className="text-[11px] font-medium text-muted-foreground/90 leading-normal">
|
||||
描述内容
|
||||
</p>
|
||||
|
||||
// 数据展示
|
||||
<span className="font-mono font-bold text-foreground text-sm tracking-tight tabular-nums">
|
||||
1716950400000
|
||||
</span>
|
||||
```
|
||||
|
||||
### 3.4 行高与字间距
|
||||
|
||||
| 属性 | 值 | 场景 |
|
||||
| ----------------- | -------- | ------------------ |
|
||||
| `leading-none` | 1 | 单行数据、紧凑布局 |
|
||||
| `leading-snug` | 1.375 | 标题、短文本 |
|
||||
| `leading-relaxed` | 1.625 | 长文本、代码块 |
|
||||
| `tracking-tight` | -0.025em | 标题、数据 |
|
||||
| `tracking-wider` | 0.05em | 大写标签 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 间距与布局
|
||||
|
||||
### 4.1 容器尺寸
|
||||
|
||||
```tsx
|
||||
// Popup 模式(默认)
|
||||
<div className="w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px]">
|
||||
|
||||
// Tab 模式(全屏自适应)
|
||||
<div className="sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||
```
|
||||
|
||||
### 4.2 间距节奏
|
||||
|
||||
| Token | 值 | 使用场景 |
|
||||
| --------------- | ----------- | ------------------ |
|
||||
| `p-3` / `p-3.5` | 12px / 14px | 页面内边距(紧凑) |
|
||||
| `p-4` | 16px | 标准页面内边距 |
|
||||
| `p-5` | 20px | 卡片内部填充 |
|
||||
| `gap-2` | 8px | 紧凑元素间距 |
|
||||
| `gap-3` | 12px | 标准元素间距 |
|
||||
| `gap-4` | 16px | 区块间距 |
|
||||
| `gap-6` | 24px | 大区块间距 |
|
||||
|
||||
### 4.3 布局模式
|
||||
|
||||
#### 页面布局
|
||||
|
||||
```tsx
|
||||
// 标准页面结构
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">{/* 页面内容 */}</div>
|
||||
```
|
||||
|
||||
#### 卡片布局
|
||||
|
||||
```tsx
|
||||
// 标准卡片
|
||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||
{/* 卡片内容 */}
|
||||
</div>
|
||||
|
||||
// 可聚焦卡片(含焦点环)
|
||||
<div className="border border-border rounded-xl bg-card ... focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
||||
```
|
||||
|
||||
#### 双栏网格
|
||||
|
||||
```tsx
|
||||
// 响应式双栏
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||
```
|
||||
|
||||
#### 工具卡片网格(Dashboard)
|
||||
|
||||
```tsx
|
||||
// Dashboard 紧凑网格
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 圆角与阴影
|
||||
|
||||
### 5.1 圆角体系
|
||||
|
||||
| Token | 值 | 使用元素 |
|
||||
| -------------- | ------ | ---------------------- |
|
||||
| `rounded-sm` | 2px | Checkbox、小标签 |
|
||||
| `rounded-md` | 6px | 按钮、输入框、Select |
|
||||
| `rounded-lg` | 8px | 搜索框、小卡片 |
|
||||
| `rounded-xl` | 12px | 大卡片、面板、图标容器 |
|
||||
| `rounded-full` | 9999px | 标签、Avatar |
|
||||
|
||||
### 5.2 阴影体系
|
||||
|
||||
| 级别 | 类名 | 用途 |
|
||||
| ---- | --------------------- | ---------------------- |
|
||||
| 无 | — | 静态元素 |
|
||||
| 低 | `shadow-sm` | 卡片、输入框、按钮 |
|
||||
| 中 | `shadow-lg` | 下拉菜单、浮层、Dialog |
|
||||
| 动态 | 自定义 `shadow-[...]` | 卡片悬停时的彩色阴影 |
|
||||
|
||||
### 5.3 彩色阴影规范(工具卡片专用)
|
||||
|
||||
```tsx
|
||||
// 工具卡片悬停阴影 — 必须使用 rgba 格式配合 CSS 变量
|
||||
className="hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||
dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件规范
|
||||
|
||||
### 6.1 Button
|
||||
|
||||
来源:`src/components/ui/button.tsx`
|
||||
|
||||
#### 变体
|
||||
|
||||
| 变体 | 类名 | 场景 |
|
||||
| ------------- | -------------------------------------------- | ------------------ |
|
||||
| `default` | `bg-primary text-primary-foreground` | 主操作 |
|
||||
| `destructive` | `bg-destructive text-destructive-foreground` | 删除、危险操作 |
|
||||
| `outline` | `border border-input bg-background` | 次级操作、取消 |
|
||||
| `secondary` | `bg-secondary text-secondary-foreground` | 次要操作 |
|
||||
| `ghost` | 仅悬停背景 | 图标按钮、低优先级 |
|
||||
| `link` | 下划线文字 | 跳转链接 |
|
||||
|
||||
#### 尺寸
|
||||
|
||||
| 尺寸 | 高度 | 内边距 | 场景 |
|
||||
| --------- | ------- | ----------- | -------- |
|
||||
| `default` | 40px | `px-4 py-2` | 标准按钮 |
|
||||
| `sm` | 36px | `px-3` | 紧凑按钮 |
|
||||
| `lg` | 44px | `px-8` | 突出按钮 |
|
||||
| `icon` | 40×40px | — | 图标按钮 |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```tsx
|
||||
// 主操作
|
||||
<Button>确认</Button>
|
||||
|
||||
// 图标按钮
|
||||
<Button variant="ghost" size="icon">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
// 危险操作
|
||||
<Button variant="destructive" size="sm">删除</Button>
|
||||
```
|
||||
|
||||
### 6.2 Input
|
||||
|
||||
来源:`src/components/ui/input.tsx`
|
||||
|
||||
```tsx
|
||||
// 标准输入框
|
||||
<Input
|
||||
className="font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background"
|
||||
/>
|
||||
|
||||
// 错误状态
|
||||
<Input
|
||||
className="border-destructive focus-visible:ring-destructive"
|
||||
/>
|
||||
```
|
||||
|
||||
**规范要点**:
|
||||
|
||||
- 高度统一为 `h-10`(40px)
|
||||
- 等宽字体用于数据输入
|
||||
- 占位符使用 `text-muted-foreground/60`
|
||||
- 错误时边框变红并调整焦点环
|
||||
|
||||
### 6.3 SwitchButtonGroup
|
||||
|
||||
来源:`src/components/ui/switch.tsx`
|
||||
|
||||
```tsx
|
||||
// 分段控制器
|
||||
<SwitchButtonGroup
|
||||
value={mode}
|
||||
options={[
|
||||
{ value: 'ts2dt', label: '转日期' },
|
||||
{ value: 'dt2ts', label: '转时间戳' },
|
||||
]}
|
||||
onChange={setMode}
|
||||
size="small"
|
||||
/>
|
||||
```
|
||||
|
||||
**规范要点**:
|
||||
|
||||
- 容器:`rounded-lg bg-muted p-1`
|
||||
- 选中项:`bg-background text-foreground shadow-sm font-semibold`
|
||||
- 未选中项:`hover:bg-background/50 hover:text-foreground/80`
|
||||
- 尺寸:`small`(32px)用于工具页,`medium`(36px)标准
|
||||
|
||||
### 6.4 Card(工具卡片)
|
||||
|
||||
来源:`src/pages/Dashboard/ToolCard.tsx`
|
||||
|
||||
```tsx
|
||||
// 标准工具卡片结构
|
||||
<div className="group relative rounded-xl border border-border/70 bg-card p-4 h-auto flex flex-col gap-3 shadow-sm">
|
||||
{/* 上半部分:图标 + 标题 + 箭头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-3 items-center">
|
||||
{/* 图标容器 */}
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[rgba(var(--tool-color),0.08)] text-[rgb(var(--tool-color))]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
{/* 文字 */}
|
||||
<div>
|
||||
<h4 className="font-bold text-sm">标题</h4>
|
||||
<p className="text-[11px] text-muted-foreground/90">描述</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
{/* 下半部分:预览区(可选) */}
|
||||
<div className="mt-1 pt-3 border-t border-dashed border-border/80">{snapshot}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 6.5 TextInputArea
|
||||
|
||||
来源:`src/components/TextInputArea.tsx`
|
||||
|
||||
```tsx
|
||||
// 多行文本输入区
|
||||
<TextInputArea
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="输入内容..."
|
||||
showCount={true}
|
||||
showClear={true}
|
||||
allowCopy={true}
|
||||
minRows={6}
|
||||
maxRows={12}
|
||||
/>
|
||||
```
|
||||
|
||||
**规范要点**:
|
||||
|
||||
- 外容器:`rounded-md border border-input bg-background shadow-sm`
|
||||
- 焦点状态:`focus-within:ring-1 focus-within:ring-ring`
|
||||
- 错误状态:`border-destructive focus-within:ring-destructive`
|
||||
- 底部工具栏:`h-10 bg-muted/30 border-t border-border/50`
|
||||
- 字体:`font-mono text-sm`
|
||||
|
||||
### 6.6 Dialog
|
||||
|
||||
来源:`src/components/ui/dialog.tsx`
|
||||
|
||||
```tsx
|
||||
// 对话框内容
|
||||
<DialogContent className="sm:rounded-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>标题</DialogTitle>
|
||||
<DialogDescription>描述文字</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* 内容 */}
|
||||
<DialogFooter>
|
||||
<Button variant="outline">取消</Button>
|
||||
<Button>确认</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
### 6.7 Select
|
||||
|
||||
来源:`src/components/ui/select.tsx`
|
||||
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger className="h-9 shadow-sm bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem className="text-xs font-semibold focus:bg-accent cursor-pointer">选项</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
### 6.8 Checkbox
|
||||
|
||||
来源:`src/components/ui/checkbox.tsx`
|
||||
|
||||
```tsx
|
||||
// 标准复选框
|
||||
<Checkbox className="h-4 w-4 rounded-sm border-primary data-[state=checked]:bg-primary" />
|
||||
|
||||
// 小型复选框(工具栏内)
|
||||
<Checkbox className="h-3.5 w-3.5 rounded border-input data-[state=checked]:bg-primary shadow-sm" />
|
||||
```
|
||||
|
||||
### 6.9 Badge
|
||||
|
||||
来源:`src/components/ui/badge.tsx`
|
||||
|
||||
| 变体 | 场景 |
|
||||
| ------------- | ------------------ |
|
||||
| `default` | 状态标签、分类 |
|
||||
| `secondary` | 次要标签 |
|
||||
| `destructive` | 错误标签 |
|
||||
| `outline` | 可点击标签、筛选器 |
|
||||
|
||||
### 6.10 CopyButton
|
||||
|
||||
来源:`src/components/CopyButton.tsx`
|
||||
|
||||
```tsx
|
||||
// 标准复制按钮
|
||||
<CopyButton text={content} />
|
||||
|
||||
// 小型复制按钮
|
||||
<CopyButton text={content} size="sm" className="h-7 w-7 rounded-md border" />
|
||||
```
|
||||
|
||||
**规范要点**:
|
||||
|
||||
- 默认 `variant="ghost" size="icon"`
|
||||
- 复制成功后变为绿色背景 + 对勾图标
|
||||
- 使用 `sonner` toast 提示复制结果
|
||||
|
||||
---
|
||||
|
||||
## 7. 交互与动效
|
||||
|
||||
### 7.1 过渡规范
|
||||
|
||||
| 属性 | 值 | 场景 |
|
||||
| ------------------- | ---------- | ----------------------------- |
|
||||
| `transition-colors` | 150ms ease | 色彩变化(悬停、焦点) |
|
||||
| `transition-all` | 150ms ease | 综合变化(SwitchButtonGroup) |
|
||||
| `duration-200` | 200ms | 复制按钮状态切换 |
|
||||
|
||||
### 7.2 焦点状态
|
||||
|
||||
所有可交互元素必须有可见的焦点指示器:
|
||||
|
||||
```tsx
|
||||
// 标准焦点环
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
|
||||
// 紧凑焦点环(图标按钮)
|
||||
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||
|
||||
// 输入框焦点
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
```
|
||||
|
||||
### 7.3 悬停状态
|
||||
|
||||
```tsx
|
||||
// 按钮悬停
|
||||
hover:bg-accent hover:text-accent-foreground
|
||||
|
||||
// 卡片悬停
|
||||
hover:bg-muted/30 hover:border-[rgba(var(--tool-color),0.45)]
|
||||
|
||||
// 链接/文字悬停
|
||||
hover:text-foreground hover:underline
|
||||
```
|
||||
|
||||
### 7.4 动画规范
|
||||
|
||||
使用 `tailwindcss-animate` 提供的动画:
|
||||
|
||||
```tsx
|
||||
// 淡入
|
||||
animate-in fade-in duration-150
|
||||
|
||||
// 淡入 + 缩放(SwitchButtonGroup 选中项)
|
||||
animate-in fade-in-50 zoom-in-95 duration-150
|
||||
|
||||
// 从顶部滑入(下拉菜单)
|
||||
animate-in fade-in slide-in-from-top-2 duration-150
|
||||
|
||||
// 错误提示出现
|
||||
animate-in fade-in slide-in-from-top-1 duration-150
|
||||
|
||||
// 骨架屏脉冲
|
||||
animate-pulse
|
||||
```
|
||||
|
||||
### 7.5 禁用状态
|
||||
|
||||
```tsx
|
||||
// 统一禁用样式
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 暗色模式
|
||||
|
||||
### 8.1 实现方式
|
||||
|
||||
通过 `darkMode: 'class'`(Tailwind 配置)+ `.dark` 类切换:
|
||||
|
||||
```tsx
|
||||
// ThemeModeProvider 自动处理
|
||||
document.documentElement.classList.toggle('dark', resolvedMode === 'dark');
|
||||
```
|
||||
|
||||
### 8.2 暗色模式下的特殊处理
|
||||
|
||||
```tsx
|
||||
// 彩色阴影增强(暗色模式下阴影需要更高透明度)
|
||||
shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||
dark:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]
|
||||
|
||||
// 图标容器背景增强
|
||||
bg-[rgba(var(--tool-color),0.08)]
|
||||
dark:bg-[rgba(var(--tool-color),0.12)]
|
||||
|
||||
// 成功状态文字调整
|
||||
text-emerald-600 dark:text-emerald-400
|
||||
```
|
||||
|
||||
### 8.3 暗色模式色彩映射原则
|
||||
|
||||
| 亮色 | 暗色 | 说明 |
|
||||
| ---------------- | ---------------- | ------------------------------- |
|
||||
| 纯白背景 | 深蓝黑背景 | 避免纯黑 `#000`,使用 `#020617` |
|
||||
| 浅灰背景 | 深灰背景 | 保持层次关系 |
|
||||
| 深文字 | 浅文字 | 反转对比度 |
|
||||
| 彩色阴影低透明度 | 彩色阴影高透明度 | 暗色需要更强视觉反馈 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 工具色彩标识
|
||||
|
||||
### 9.1 色板定义
|
||||
|
||||
每个工具分配一个主题色,定义于 `src/config/features.tsx`:
|
||||
|
||||
```ts
|
||||
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||
primary: '13, 148, 136', // teal (#0d9488)
|
||||
success: '22, 163, 74', // green (#16a34a)
|
||||
warning: '217, 119, 6', // amber (#d97706)
|
||||
error: '220, 38, 38', // red (#dc2626)
|
||||
secondary: '147, 51, 232', // purple (#9333e8)
|
||||
info: '37, 99, 235', // blue (#2563eb)
|
||||
};
|
||||
```
|
||||
|
||||
### 9.2 工具色彩分配
|
||||
|
||||
| 工具 | 色彩键 | 色值 |
|
||||
| ----------- | ----------- | ------ |
|
||||
| 时间戳转换 | `primary` | Teal |
|
||||
| 存储清理 | `warning` | Amber |
|
||||
| 二维码工具 | `success` | Green |
|
||||
| 文本统计 | `secondary` | Purple |
|
||||
| JWT 解析 | `info` | Blue |
|
||||
| JSON 对比 | `primary` | Teal |
|
||||
| Base64 转换 | `info` | Blue |
|
||||
| 右键还原 | `success` | Green |
|
||||
|
||||
### 9.3 工具色彩使用规范
|
||||
|
||||
```tsx
|
||||
// 1. 通过 style 注入 CSS 变量
|
||||
<div style={{ ['--tool-color' as string]: rgbValues }}>
|
||||
|
||||
// 2. 图标容器背景(低透明度)
|
||||
bg-[rgba(var(--tool-color),0.08)]
|
||||
dark:bg-[rgba(var(--tool-color),0.12)]
|
||||
|
||||
// 3. 图标颜色
|
||||
text-[rgb(var(--tool-color))]
|
||||
|
||||
// 4. 悬停边框
|
||||
hover:border-[rgba(var(--tool-color),0.45)]
|
||||
|
||||
// 5. 悬停阴影
|
||||
hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||
|
||||
// 6. 箭头悬停色
|
||||
group-hover:text-[rgb(var(--tool-color))]
|
||||
```
|
||||
|
||||
**注意**:工具色彩仅用于**标识和装饰**,不得用于功能性色彩(如成功/错误状态)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 代码规范
|
||||
|
||||
### 10.1 Tailwind 类名组织顺序
|
||||
|
||||
使用 `cn()` 工具函数(`clsx` + `tailwind-merge`)组合类名,按以下顺序排列:
|
||||
|
||||
```tsx
|
||||
className={cn(
|
||||
// 1. 布局(display, position, flex, grid)
|
||||
'flex items-center justify-between',
|
||||
// 2. 尺寸(width, height, padding, margin)
|
||||
'w-full h-10 px-4',
|
||||
// 3. 外观(background, border, shadow, rounded)
|
||||
'rounded-md border border-input bg-background shadow-sm',
|
||||
// 4. 文字(color, font, text-align)
|
||||
'text-sm font-medium text-foreground',
|
||||
// 5. 交互(hover, focus, disabled, cursor)
|
||||
'hover:bg-accent focus-visible:ring-2 disabled:opacity-50',
|
||||
// 6. 动画(transition, animate)
|
||||
'transition-colors duration-150',
|
||||
// 7. 条件类
|
||||
isActive && 'bg-primary text-primary-foreground',
|
||||
// 8. 外部传入
|
||||
className,
|
||||
)}
|
||||
```
|
||||
|
||||
### 10.2 颜色使用检查清单
|
||||
|
||||
- [ ] 所有颜色使用 CSS 变量(`bg-background` 而非 `bg-white`)
|
||||
- [ ] 边框使用 `border-border` 及其透明度变体
|
||||
- [ ] 文字层级使用 `foreground` → `muted-foreground` → `muted-foreground/60`
|
||||
- [ ] 错误状态使用 `destructive` 系列
|
||||
- [ ] 工具色彩仅用于装饰性元素
|
||||
|
||||
### 10.3 组件文件组织
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ui/ # shadcn 基础组件(只读,不修改)
|
||||
├── components/ # 业务组件
|
||||
│ ├── CopyButton.tsx
|
||||
│ ├── SwitchButtonGroup.tsx
|
||||
│ ├── TextInputArea.tsx
|
||||
│ └── ...
|
||||
├── pages/ # 页面组件
|
||||
│ ├── <ToolName>/
|
||||
│ │ ├── index.tsx # 页面入口
|
||||
│ │ ├── use<ToolName>.ts # 业务逻辑 Hook
|
||||
│ │ └── components/ # 页面私有组件
|
||||
│ └── ...
|
||||
└── providers/ # Context Providers
|
||||
```
|
||||
|
||||
### 10.4 新增页面模板
|
||||
|
||||
```tsx
|
||||
// src/pages/NewTool/index.tsx
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 select-none">
|
||||
{/* 页面内容 */}
|
||||
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||
<h4 className="font-bold text-sm tracking-tight">新工具</h4>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 反模式清单
|
||||
|
||||
以下模式**禁止**在项目中使用:
|
||||
|
||||
### 11.1 色彩反模式
|
||||
|
||||
```tsx
|
||||
// ❌ 硬编码颜色
|
||||
<div className="bg-white text-black">
|
||||
<div className="bg-gray-100">
|
||||
<div className="text-gray-500">
|
||||
|
||||
// ❌ 使用非语义化 Tailwind 颜色
|
||||
<div className="bg-slate-50">
|
||||
<div className="text-zinc-400">
|
||||
|
||||
// ✅ 使用 CSS 变量
|
||||
<div className="bg-background text-foreground">
|
||||
<div className="bg-muted text-muted-foreground">
|
||||
```
|
||||
|
||||
### 11.2 布局反模式
|
||||
|
||||
```tsx
|
||||
// ❌ 固定高度导致内容截断
|
||||
<div className="h-[200px]">
|
||||
|
||||
// ✅ 使用 min-height 或自适应
|
||||
<div className="min-h-[200px]">
|
||||
<div className="h-auto">
|
||||
|
||||
// ❌ 使用 margin 做组件间距
|
||||
<div className="mb-4">
|
||||
|
||||
// ✅ 使用 gap
|
||||
<div className="flex flex-col gap-4">
|
||||
```
|
||||
|
||||
### 11.3 组件反模式
|
||||
|
||||
```tsx
|
||||
// ❌ 修改 shadcn/ui 基础组件样式
|
||||
// 如需修改,通过 className 覆盖或创建包装组件
|
||||
|
||||
// ❌ 内联样式用于颜色(工具色彩除外)
|
||||
<div style={{ backgroundColor: '#f1f5f9' }}>
|
||||
|
||||
// ❌ 混合使用不同圆角体系
|
||||
<Button className="rounded-lg"> // Button 应为 rounded-md
|
||||
|
||||
// ❌ 忽略焦点状态
|
||||
<button className="..."> // 缺少 focus-visible 样式
|
||||
```
|
||||
|
||||
### 11.4 暗色模式反模式
|
||||
|
||||
```tsx
|
||||
// ❌ 仅适配部分元素
|
||||
<div className="bg-white text-black dark:bg-gray-900 dark:text-white">
|
||||
|
||||
// ✅ 使用 CSS 变量自动适配
|
||||
<div className="bg-background text-foreground">
|
||||
|
||||
// ❌ 暗色模式下使用不合适的透明度
|
||||
<div className="bg-black/5 dark:bg-white/5"> // 对比度不足
|
||||
|
||||
// ✅ 使用语义化变量
|
||||
<div className="bg-muted">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 附录
|
||||
|
||||
### 12.1 常用类名速查
|
||||
|
||||
```
|
||||
// 页面容器
|
||||
p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none
|
||||
|
||||
// 标准卡片
|
||||
p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm
|
||||
|
||||
// 工具栏
|
||||
flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60
|
||||
|
||||
// 区域标签
|
||||
text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider
|
||||
|
||||
// 数据展示
|
||||
font-mono font-semibold text-foreground text-sm
|
||||
|
||||
// 错误提示
|
||||
text-xs font-medium text-destructive
|
||||
|
||||
// 空状态
|
||||
p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center
|
||||
|
||||
// 图标按钮容器
|
||||
flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background shadow-sm
|
||||
|
||||
// 焦点环
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
```
|
||||
|
||||
### 12.2 相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------- | ----------------------- |
|
||||
| `src/index.css` | CSS 变量定义、全局样式 |
|
||||
| `tailwind.config.js` | Tailwind 配置、色彩映射 |
|
||||
| `src/lib/utils.ts` | `cn()` 工具函数 |
|
||||
| `src/components/ui/*.tsx` | shadcn/ui 基础组件 |
|
||||
| `src/config/features.tsx` | 工具配置、色彩分配 |
|
||||
| `src/providers/ThemeModeProvider.tsx` | 主题模式管理 |
|
||||
|
||||
### 12.3 参考资源
|
||||
|
||||
- [shadcn/ui 文档](https://ui.shadcn.com/docs)
|
||||
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
|
||||
- [Radix UI 文档](https://www.radix-ui.com/)
|
||||
- [Lucide Icons](https://lucide.dev/)
|
||||
|
||||
---
|
||||
|
||||
_本文档随项目迭代更新。新增组件或修改视觉风格时,请同步更新此文档。_
|
||||
@@ -0,0 +1,8 @@
|
||||
# Spec 目录
|
||||
|
||||
功能规格、修复方案与验收标准文档。
|
||||
|
||||
| 文档 | 状态 | 说明 |
|
||||
| -------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------- |
|
||||
| [storage-cleaner/indexeddb-fix-plan.md](./storage-cleaner/indexeddb-fix-plan.md) | ✅ Phase 3 已完成 | Storage Cleaner IndexedDB 清理逻辑修复方案与验收标准 |
|
||||
| [popup-layout/feature-nav-layout.md](./popup-layout/feature-nav-layout.md) | 待实施 | Popup 右侧图标导航、移除 Dashboard、默认 timestamp |
|
||||
@@ -0,0 +1,356 @@
|
||||
# Popup 布局重构 — 功能导航与 Dashboard 移除
|
||||
|
||||
> 创建时间: 2026-07-05
|
||||
> 状态: 待实施
|
||||
> 关联模块: `src/entrypoints/popup/`、`src/layout/FeatureNav/`、`src/providers/RouterProvider.tsx`
|
||||
> 影响范围: 仅 popup 入口(tab / sidepanel 不在本次布局范围)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
当前 popup 采用 **Dashboard 首页 + TopBar 搜索跳转** 模式:用户打开扩展后先看到仪表盘卡片网格,再点击进入具体工具。布局为固定 400×600(见 `src/entrypoints/popup/App.tsx` 与 `src/entrypoints/popup/index.html`),TopBar 在非 dashboard 页面显示「返回首页」按钮。
|
||||
|
||||
该模式增加了一步选工具操作。本次重构将 popup 改为 **工具直达 + 右侧常驻导航**,并移除 Dashboard 页面。
|
||||
|
||||
| 维度 | 现状 | 目标 |
|
||||
| ---------- | ----------------------------- | ----------------------------------------- |
|
||||
| 默认页 | `dashboard` | 新用户默认 `timestamp` |
|
||||
| 导航 | Dashboard 卡片网格 + 搜索跳转 | 右侧常驻纯图标导航栏 |
|
||||
| 首页 | Dashboard 独立页 | 移除 |
|
||||
| Popup 宽度 | 400px | 450px(内容区 ~402px + 导航栏 48px) |
|
||||
| 作用范围 | — | 仅 popup 改布局;tab / sidepanel 不加导航 |
|
||||
|
||||
---
|
||||
|
||||
## 布局设计
|
||||
|
||||
### 结构示意
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph popup ["Popup 450x600"]
|
||||
subgraph mainCol ["主列 flex-1"]
|
||||
TopBar["TopBar"]
|
||||
Content["RouterContainer"]
|
||||
TopBar --> Content
|
||||
end
|
||||
NavCol["FeatureNav 48px"]
|
||||
end
|
||||
mainCol --- NavCol
|
||||
```
|
||||
|
||||
### 尺寸约束
|
||||
|
||||
| 属性 | 值 | 说明 |
|
||||
| -------- | ------ | ---------------------------------------------- |
|
||||
| 总宽度 | 450px | `App.tsx` 容器 + `index.html` 内联样式同步修改 |
|
||||
| 总高度 | 600px | 不变 |
|
||||
| 主内容列 | flex-1 | 约 402px,需 `min-w-0` 防止 flex 子项溢出 |
|
||||
| 导航栏 | 48px | Tailwind `w-12`,固定宽度,不参与 flex 收缩 |
|
||||
|
||||
### 视觉规范
|
||||
|
||||
- 导航栏:左侧 `border-l border-border`,垂直排列图标按钮
|
||||
- 图标按钮:`h-9 w-9` 或 `h-10 w-10`,纯图标
|
||||
- Tooltip:使用原生 `title` 属性 + `aria-label={feature.label}`,不引入 Tooltip 组件
|
||||
- Active 态:背景 `bg-muted` + 左侧 `border-l-2 border-primary`
|
||||
- 导航列表超出高度时:`overflow-y-auto`(600px 高度下 9 个工具通常无需滚动)
|
||||
|
||||
---
|
||||
|
||||
## FeatureNav 组件规格
|
||||
|
||||
新建 `src/layout/FeatureNav/`,遵循 UI + Hook 分离模式(见 `.github/CODING_STANDARDS.md` §11)。
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/layout/FeatureNav/
|
||||
├── index.tsx # 垂直图标按钮列表、active 高亮、无障碍属性
|
||||
├── useFeatureNav.ts # 读路由状态,暴露 navItems / navigateTo / currentPage
|
||||
├── resolveNavFeatures.ts # 从 Dashboard 迁出的功能列表解析逻辑
|
||||
└── __tests__/
|
||||
├── index.test.tsx
|
||||
└── resolveNavFeatures.test.ts
|
||||
```
|
||||
|
||||
### resolveNavFeatures.ts
|
||||
|
||||
从 `src/pages/Dashboard/dashboardFeatures.ts` 迁出并重命名:
|
||||
|
||||
```typescript
|
||||
export interface NavFeatureItem {
|
||||
key: PageType;
|
||||
feature: FeatureConfig & { icon: NonNullable<FeatureConfig['icon']> };
|
||||
}
|
||||
|
||||
export function resolveNavFeatures(keys: PageType[], visiblePages: PageType[]): NavFeatureItem[];
|
||||
```
|
||||
|
||||
逻辑与旧 `resolveDashboardFeatures` 一致:
|
||||
|
||||
- 按 `keys`(即 `pageOrder`)顺序遍历
|
||||
- 跳过不在 `visiblePages` 中的项
|
||||
- 跳过无 `icon` 的 feature(dashboard 移除后不应出现)
|
||||
|
||||
### useFeatureNav.ts
|
||||
|
||||
```typescript
|
||||
export interface UseFeatureNavReturn {
|
||||
navItems: NavFeatureItem[];
|
||||
currentPage: PageType;
|
||||
navigateTo: (page: PageType) => void;
|
||||
}
|
||||
```
|
||||
|
||||
- 从 `useRouter()` 读取 `currentPage`、`pageOrder`、`visiblePages`、`navigateTo`
|
||||
- 用 `useMemo` 调用 `resolveNavFeatures(pageOrder, visiblePages)` 生成 `navItems`
|
||||
|
||||
### index.tsx
|
||||
|
||||
- 渲染 `<nav aria-label="功能导航">` 包裹垂直按钮列表
|
||||
- 每项为 `<button type="button">`,内含 Lucide 图标
|
||||
- 点击调用 `navigateTo(item.key)`
|
||||
- 当前项添加 active 样式与 `aria-current="page"`
|
||||
|
||||
### 引用范围
|
||||
|
||||
**仅** `src/entrypoints/popup/App.tsx` 引用 `FeatureNav`。sidepanel / tab 入口不引入该组件。
|
||||
|
||||
---
|
||||
|
||||
## popup/App.tsx 目标结构
|
||||
|
||||
```tsx
|
||||
import RouterProvider from '@/providers/RouterProvider';
|
||||
import TopBar from '@/layout/TopBar';
|
||||
import FeatureNav from '@/layout/FeatureNav';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<RouterProvider
|
||||
defaultRoute="timestamp"
|
||||
syncKey="app/popupRoute"
|
||||
visiblePagesKey="app/popupVisiblePages"
|
||||
pageOrderKey="app/popupPageOrder"
|
||||
>
|
||||
<div className="flex w-[450px] max-w-[450px] min-w-[450px] h-[600px] min-h-[600px] overflow-hidden bg-background">
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<TopBar />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<FeatureNav />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`src/entrypoints/popup/index.html` 内联样式中 `width: 400px` 改为 `width: 450px`。
|
||||
|
||||
---
|
||||
|
||||
## 移除 Dashboard(全局类型变更)
|
||||
|
||||
布局仅改 popup,但 `dashboard` 从 `PageType` 移除会影响全项目类型与路由校验。
|
||||
|
||||
### 删除文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ------------------------------------- | ---------------- |
|
||||
| `src/pages/Dashboard/` | 整目录(含测试) |
|
||||
| `src/config/pageLoaders/dashboard.ts` | Dashboard 懒加载 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
| ------------------------------------ | ----------------------------------------------------------- |
|
||||
| `src/types/storage.d.ts` | 从 `PageType` 联合类型删除 `'dashboard'` |
|
||||
| `src/config/featureMeta.ts` | 删除 dashboard 条目 |
|
||||
| `src/config/pageLoaders/index.ts` | 删除 `case 'dashboard'` |
|
||||
| `src/components/RouterContainer.tsx` | 去掉 dashboard 骨架屏分支;统一使用 `page-transition-enter` |
|
||||
| `src/components/PageSkeleton.tsx` | 删除 `variant: 'dashboard'` 及对应 UI |
|
||||
| `src/styles/shell.css` | 删除 `.page-transition-dashboard` |
|
||||
|
||||
### TopBar 精简
|
||||
|
||||
`src/layout/TopBar/index.tsx` 与 `useTopBar.ts`:
|
||||
|
||||
- 删除「返回首页」按钮(`ArrowLeft` + `goHome`)
|
||||
- 删除 `isDashboard` 状态及相关导出
|
||||
- 搜索过滤中 `f.key === 'dashboard'` 的特殊排除逻辑可删除
|
||||
|
||||
### RouterProvider 变更
|
||||
|
||||
`src/providers/RouterProvider.tsx`:
|
||||
|
||||
- 删除 `goHome()` 方法及 Context 中的 `goHome` 字段
|
||||
- 全局 `defaultRoute` 默认值由 `'dashboard'` 改为 `'timestamp'`
|
||||
- popup 显式传入 `defaultRoute="timestamp"`(与全局默认一致,便于阅读)
|
||||
|
||||
### Sidepanel 最小修复
|
||||
|
||||
`src/entrypoints/sidepanel/App.tsx` 若仍存在:
|
||||
|
||||
- `defaultRoute="dashboard"` 改为 `defaultRoute="timestamp"`
|
||||
- 不添加 FeatureNav 布局
|
||||
|
||||
---
|
||||
|
||||
## 默认路由策略(仅新用户)
|
||||
|
||||
利用现有 `RouterProvider` 机制,**无需额外迁移代码**。
|
||||
|
||||
```typescript
|
||||
// 首屏(localStorage 快照,消除闪烁)
|
||||
const [currentPage] = useState(() => getSyncSnapshot(syncKey, defaultRoute, isValidPage));
|
||||
|
||||
// 异步加载 chrome.storage
|
||||
const savedRoute = (stored[syncKey] ?? defaultRoute) as PageType;
|
||||
if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
|
||||
setCurrentPage(savedRoute);
|
||||
}
|
||||
```
|
||||
|
||||
| 场景 | 行为 |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| 新用户(无 `app/popupRoute`) | 首屏 + 持久化均为 `timestamp` |
|
||||
| 老用户(如 `app/popupRoute: 'jsonTools'`) | 恢复上次路由 |
|
||||
| 老用户曾存 `dashboard` | `isValidPage('dashboard')` 为 false → 回退 `timestamp`(软迁移) |
|
||||
| 右键菜单 deep link | `?feature=&payload=` 优先级不变,高于默认路由 |
|
||||
| 用户主动切换 | `navigateTo` 后 `hasUserNavigatedRef` 阻止异步覆盖 |
|
||||
|
||||
`visiblePages` / `pageOrder` 中残留的 `dashboard` 由 `mergeWithDefaults` 自动滤除(不在新默认列表中)。
|
||||
|
||||
### 边界说明
|
||||
|
||||
`recentlyUsedTools` 若含已移除的 `dashboard` 等非法项,当前 `isValidPageList` 校验要求数组**全部**合法,失败时整表回退为 `[]`。影响:TopBar 搜索历史中的「最近使用」可能被清空。此为已知低影响边界;实现时可选优化为「过滤非法项保留合法项」,**非必须**。
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```
|
||||
1. 迁出 resolveNavFeatures + 新建 FeatureNav 组件与测试
|
||||
2. 修改 popup/App.tsx 布局 + popup/index.html 宽度
|
||||
3. 全局移除 dashboard(PageType → FEATURES → pageLoader → 删页面目录)
|
||||
4. 精简 TopBar / RouterProvider / RouterContainer / PageSkeleton
|
||||
5. 批量更新测试 → npm run typecheck && npm run test && npm run lint
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### A. 布局与导航(手动)
|
||||
|
||||
| 编号 | 步骤 | 期望 |
|
||||
| ---- | ------------------------------------ | ---------------------------------------------- |
|
||||
| A-1 | 首次安装(或清空 storage)打开 popup | 默认显示时间戳页,右侧导航 timestamp 项高亮 |
|
||||
| A-2 | 依次点击各导航图标 | 切换到对应工具页,active 态跟随当前页 |
|
||||
| A-3 | hover 各导航图标 | 显示中文功能名 tooltip(`title` 属性) |
|
||||
| A-4 | 使用过某工具后关闭再打开 popup | 恢复上次路由(非 dashboard) |
|
||||
| A-5 | 检查 popup 尺寸 | 宽度 450px,高度 600px,内容区不被导航挤压溢出 |
|
||||
|
||||
### B. Dashboard 移除
|
||||
|
||||
| 编号 | 期望 |
|
||||
| ---- | -------------------------------------------------------------------- |
|
||||
| B-1 | TopBar 搜索下拉无 dashboard 项 |
|
||||
| B-2 | TopBar 无「返回首页」按钮 |
|
||||
| B-3 | `PageType`、`FEATURES`、`loadPage` 中无 dashboard |
|
||||
| B-4 | storage 中 `app/popupRoute: 'dashboard'` 时打开 popup 落到 timestamp |
|
||||
| B-5 | `visiblePages` 含 dashboard 时自动滤除,导航正常显示工具列表 |
|
||||
|
||||
### C. 单元测试
|
||||
|
||||
| 编号 | 覆盖 |
|
||||
| ---- | -------------------------------------------------------------------------- |
|
||||
| C-1 | `FeatureNav` 渲染全部可见工具图标 |
|
||||
| C-2 | `FeatureNav` 点击图标调用 `navigateTo` |
|
||||
| C-3 | `FeatureNav` 当前页对应项有 active 样式 / `aria-current` |
|
||||
| C-4 | `resolveNavFeatures` 按 pageOrder 排序、respect visiblePages |
|
||||
| C-5 | 更新 `RouterProvider` / `TopBar` / `RouterContainer` / `features` 相关测试 |
|
||||
| C-6 | 删除 `src/pages/Dashboard/__tests__/` 下全部测试 |
|
||||
|
||||
### D. CI
|
||||
|
||||
| 编号 | 标准 |
|
||||
| ---- | ------------------------ |
|
||||
| D-1 | `npm run typecheck` 通过 |
|
||||
| D-2 | `npm run test` 通过 |
|
||||
| D-3 | `npm run lint` 通过 |
|
||||
|
||||
---
|
||||
|
||||
## 不在范围
|
||||
|
||||
- tab / sidepanel 布局改造(不加 FeatureNav)
|
||||
- `visiblePages` / `pageOrder` 可视化配置 UI
|
||||
- Popup 高度变更(保持 600px)
|
||||
- 修改 `AGENTS.md` 中 spec 目录引用(实际 spec 位于 `docs/spec/`)
|
||||
|
||||
---
|
||||
|
||||
## PR 检查清单
|
||||
|
||||
```markdown
|
||||
## 功能
|
||||
|
||||
- [ ] 新建 FeatureNav 组件(index / useFeatureNav / resolveNavFeatures)
|
||||
- [ ] popup 布局改为双列(主内容 + 右侧导航)
|
||||
- [ ] popup 宽度 450px(App.tsx + index.html)
|
||||
- [ ] popup defaultRoute="timestamp"
|
||||
- [ ] 全局移除 dashboard(PageType、FEATURES、pageLoader、页面目录)
|
||||
- [ ] TopBar 移除返回按钮与 goHome 相关逻辑
|
||||
- [ ] RouterProvider 移除 goHome,defaultRoute 改为 timestamp
|
||||
- [ ] sidepanel defaultRoute 最小修复(若文件存在)
|
||||
|
||||
## 测试
|
||||
|
||||
- [ ] FeatureNav / resolveNavFeatures 单元测试
|
||||
- [ ] 更新 RouterProvider / TopBar / RouterContainer / features 测试
|
||||
- [ ] 删除 Dashboard 相关测试
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] npm run typecheck / test / lint 通过
|
||||
- [ ] 手动 A-1 ~ A-5、B-1 ~ B-4 通过
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险与边界说明
|
||||
|
||||
1. **Dashboard 移除为全局变更**:虽 FeatureNav 仅 popup 使用,但 `PageType` 删除 `dashboard` 会影响所有入口的类型校验与路由合法性。
|
||||
2. **老用户 dashboard 路由软迁移**:依赖 `isValidPage` 回退到 `defaultRoute`,不会 crash,但用户会察觉「首页消失、默认变为时间戳」——符合预期。
|
||||
3. **450px 宽度**:Chrome popup 允许扩展较默认更宽;若未来 Firefox 有差异需在 Firefox 构建中手动验证。
|
||||
4. **recentlyUsedTools 整表校验**:见「默认路由策略 → 边界说明」,可选后续优化。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------- | ------------------------------- |
|
||||
| `src/entrypoints/popup/App.tsx` | popup 布局入口,引用 FeatureNav |
|
||||
| `src/entrypoints/popup/index.html` | popup 初始尺寸 450px |
|
||||
| `src/layout/FeatureNav/` | 新建:右侧图标导航 |
|
||||
| `src/layout/TopBar/` | 精简:移除返回按钮 |
|
||||
| `src/providers/RouterProvider.tsx` | 默认路由、移除 goHome |
|
||||
| `src/config/featureMeta.ts` | FEATURES 定义,删除 dashboard |
|
||||
| `src/types/storage.d.ts` | PageType 定义 |
|
||||
| `src/config/pageLoaders/index.ts` | 页面懒加载路由 |
|
||||
| `src/components/RouterContainer.tsx` | 页面渲染容器 |
|
||||
| `src/components/PageSkeleton.tsx` | 加载骨架屏 |
|
||||
| `src/styles/shell.css` | 页面过渡动画 |
|
||||
| `src/pages/Dashboard/` | **删除** |
|
||||
| `src/config/pageLoaders/dashboard.ts` | **删除** |
|
||||
| `src/entrypoints/sidepanel/App.tsx` | 最小修复 defaultRoute |
|
||||
|
||||
---
|
||||
|
||||
> **注**:项目 spec 文档实际目录为 `docs/spec/`。`AGENTS.md` 中引用的根目录 `spec/README.md` 尚未建立,以本目录 [`README.md`](../README.md) 为索引。
|
||||
@@ -0,0 +1,435 @@
|
||||
# Storage Cleaner — IndexedDB 修复方案与验收标准
|
||||
|
||||
> 创建时间: 2026-06-25
|
||||
> 状态: ✅ Phase 3 已完成(2026-06-25)
|
||||
> 关联模块: `src/utils/storageCleaner.ts`
|
||||
> 前置审查: Code Review(`storageCleaner.ts` 修改版)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
本次修复针对 `storageCleaner.ts` 中 IndexedDB 清理逻辑及错误处理链路的审查结论,按优先级分三阶段实施。
|
||||
|
||||
| 问题域 | 现状 | 目标 |
|
||||
| ------------------- | ---------------------------------------------- | --------------------------------- |
|
||||
| IndexedDB fallback | `store.clear()` 完成后立即 `db.close()` | 等 transaction commit 后再关闭 |
|
||||
| deleteDatabase 超时 | 超时后仍可能触发 `onsuccess`,与 fallback 并发 | 单次删除生命周期内只 resolve 一次 |
|
||||
| 部分成功 | 多 DB 部分失败时 `count` 丢失 | 失败结果保留已清理数量 |
|
||||
| 代码结构 | `runScript` / `runCleanScript` 重复 | 统一 executeScript 入口 |
|
||||
| 测试 | 缺多 DB 混合场景 | 补单元测试覆盖 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — 合并前必做(P1)
|
||||
|
||||
### 1.1 等待 IndexedDB transaction 完成后再关闭连接
|
||||
|
||||
#### 问题
|
||||
|
||||
`clearObjectStores` 在 `Promise.all(clearStore...)` 结束后立刻 `db.close()`。单个 `clearReq.onsuccess` 只表示 request 完成,transaction 可能尚未 commit,存在清空被回滚的风险。
|
||||
|
||||
#### 根因
|
||||
|
||||
IndexedDB 规范中,transaction 的持久化以 `transaction.oncomplete` 为准,而非单个 request 的 `onsuccess`。
|
||||
|
||||
#### 修复方案
|
||||
|
||||
在注入脚本内的 `clearObjectStores` 中,增加 `waitForTransaction` 辅助函数:
|
||||
|
||||
```typescript
|
||||
const waitForTransaction = (tx: IDBTransaction): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error ?? new Error('Transaction failed'));
|
||||
tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted'));
|
||||
});
|
||||
```
|
||||
|
||||
修改 `openReq.onsuccess` 分支:
|
||||
|
||||
```typescript
|
||||
const transaction = db.transaction(storeNames, 'readwrite');
|
||||
const errors = (
|
||||
await Promise.all(
|
||||
storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)),
|
||||
)
|
||||
).filter((error): error is string => Boolean(error));
|
||||
|
||||
try {
|
||||
await waitForTransaction(transaction);
|
||||
} catch {
|
||||
db.close();
|
||||
resolve({
|
||||
success: false,
|
||||
errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
db.close();
|
||||
resolve({ success: errors.length === 0, errors });
|
||||
```
|
||||
|
||||
#### 涉及文件
|
||||
|
||||
- `src/utils/storageCleaner.ts` — `injectClearIndexedDB` 内 `clearObjectStores`
|
||||
|
||||
#### 新增测试
|
||||
|
||||
```typescript
|
||||
it('should wait for transaction complete before closing db', async () => {
|
||||
// mock: clear onsuccess 先于 transaction.oncomplete 触发
|
||||
// 断言 db.close 在 transaction.oncomplete 之后调用
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 消除 deleteDatabase 超时竞态
|
||||
|
||||
#### 问题
|
||||
|
||||
超时 `resolve('timeout')` 后,`deleteReq.onsuccess` 仍可能触发;此时 fallback 的 `indexedDB.open` 与进行中的 `deleteDatabase` 可能并发,行为未定义。
|
||||
|
||||
#### 修复方案
|
||||
|
||||
为每个 DB 删除引入 **单次 settle** 状态:
|
||||
|
||||
```typescript
|
||||
const waitForDeleteDatabase = (dbName: string, timeoutMs: number) =>
|
||||
new Promise<'deleted' | 'blocked' | 'timeout' | 'error'>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (status: 'deleted' | 'blocked' | 'timeout' | 'error') => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(status);
|
||||
};
|
||||
|
||||
const deleteReq = indexedDB.deleteDatabase(dbName);
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('IndexedDB delete timeout:', dbName);
|
||||
settle('timeout');
|
||||
}, timeoutMs);
|
||||
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', dbName);
|
||||
settle('blocked');
|
||||
};
|
||||
deleteReq.onsuccess = () => settle('deleted');
|
||||
deleteReq.onerror = () => settle('error');
|
||||
});
|
||||
```
|
||||
|
||||
#### timeout / blocked 后的 fallback 策略
|
||||
|
||||
| 状态 | 行为 |
|
||||
| --------- | ------------------------------------------------------------------ |
|
||||
| `blocked` | 立即 fallback `clearObjectStores`(页面仍占用连接,open 通常可行) |
|
||||
| `timeout` | 先 `await delay(100~200ms)` 再 fallback,降低与 delete 并发概率 |
|
||||
| `error` | 不 fallback,直接报错 |
|
||||
|
||||
#### 涉及文件
|
||||
|
||||
- `src/utils/storageCleaner.ts` — 替换现有 `new Promise` 删除逻辑
|
||||
|
||||
#### 新增测试
|
||||
|
||||
```typescript
|
||||
it('should ignore late onsuccess after delete timeout', async () => {
|
||||
// deleteBehavior: timeout,5000ms 后 resolve timeout
|
||||
// 6000ms 后再触发 onsuccess
|
||||
// 断言:只走 fallback 一次,count 不因 late onsuccess 重复 +1
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — 建议同 PR 或紧接 follow-up(P2)
|
||||
|
||||
### 2.1 IndexedDB 部分成功时保留 count
|
||||
|
||||
#### 问题
|
||||
|
||||
多 DB 场景返回 `{ count: 2, errors: ['...'] }` 时,`runCleanScript` 只返回 `{ success: false, error }`,用户看不到已清理 2 个库。
|
||||
|
||||
#### 修复方案(推荐)
|
||||
|
||||
扩展失败分支类型,可选 `count`:
|
||||
|
||||
```typescript
|
||||
// src/types/storage.d.ts
|
||||
export type StorageCleanResult =
|
||||
| { success: true; count: number }
|
||||
| { success: false; error: string; count?: number }; // 部分成功时的已清理数
|
||||
```
|
||||
|
||||
修改 `runCleanScript`:
|
||||
|
||||
```typescript
|
||||
if (raw.errors?.length) {
|
||||
const errorMsg = raw.errors.join('\n');
|
||||
const partialHint = raw.count > 0 ? `(已成功清理 ${raw.count} 个数据库,但部分失败)\n` : '';
|
||||
return {
|
||||
success: false,
|
||||
error: partialHint + errorMsg,
|
||||
...(raw.count > 0 ? { count: raw.count } : {}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### UI 层(可选增强)
|
||||
|
||||
`CleaningResult.tsx` 失败时若 `result.indexedDB?.count` 存在,可展示部分成功提示(非必须,error 字符串已含 hint 即可)。
|
||||
|
||||
#### 涉及文件
|
||||
|
||||
- `src/types/storage.d.ts`
|
||||
- `src/utils/storageCleaner.ts` — `runCleanScript`
|
||||
- `src/utils/__tests__/storageCleaner.test.ts`
|
||||
- (可选)`src/pages/StorageCleaner/components/CleaningResult.tsx`
|
||||
|
||||
#### 新增测试
|
||||
|
||||
```typescript
|
||||
it('should preserve partial count when some IndexedDB databases fail', async () => {
|
||||
// 3 个 DB:2 成功删除,1 blocked 且 fallback 失败
|
||||
// expect: success false, count 2, error 含「已成功清理 2 个」
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 统一 executeScript 调用入口
|
||||
|
||||
#### 问题
|
||||
|
||||
`runScript` 与 `runCleanScript` 各自调用 `browser.scripting.executeScript`,行为不一致(吞错 vs 抛错)。
|
||||
|
||||
#### 修复方案
|
||||
|
||||
抽取底层函数:
|
||||
|
||||
```typescript
|
||||
type ExecuteScriptMode = 'fallback' | 'throw';
|
||||
|
||||
async function executeInTab<T>(
|
||||
tabId: number,
|
||||
func: () => T | Promise<T>,
|
||||
options: { errorLabel: string; mode: 'fallback'; fallback: T },
|
||||
): Promise<T>;
|
||||
async function executeInTab<T>(
|
||||
tabId: number,
|
||||
func: () => T | Promise<T>,
|
||||
options: { errorLabel: string; mode: 'throw' },
|
||||
): Promise<T>;
|
||||
async function executeInTab<T>(...) {
|
||||
try {
|
||||
const [result] = await browser.scripting.executeScript({ target: { tabId }, func });
|
||||
return (result?.result as T) ?? (options.mode === 'fallback' ? options.fallback : undefined as T);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${options.errorLabel}:`, error);
|
||||
if (options.mode === 'throw') throw error;
|
||||
return options.fallback;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `runScript` → `executeInTab(..., { mode: 'fallback', fallback })`
|
||||
- `runCleanScript` → `executeInTab(..., { mode: 'throw' })` + 结果解析
|
||||
|
||||
#### 涉及文件
|
||||
|
||||
- `src/utils/storageCleaner.ts`
|
||||
|
||||
#### 验收
|
||||
|
||||
现有 11 个测试全部通过,无行为回归。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 错误信息分隔符统一
|
||||
|
||||
#### 问题
|
||||
|
||||
`runCleanScript` 用 `'; '` 拼接,`clearStorage` 的 `result.error` 用 `'\n'`,UI 用 `break-all` 展示,多错误时可读性不一致。
|
||||
|
||||
#### 修复方案
|
||||
|
||||
IndexedDB 内部多错误统一改为 `'\n'`:
|
||||
|
||||
```typescript
|
||||
error: raw.errors.join('\n');
|
||||
```
|
||||
|
||||
#### 涉及文件
|
||||
|
||||
- `src/utils/storageCleaner.ts`
|
||||
- 相关测试断言(若有 `'; '` 期望)
|
||||
|
||||
---
|
||||
|
||||
### 2.4 补充多 DB 混合场景测试
|
||||
|
||||
| 用例 | 输入 | 期望 |
|
||||
| ------------------ | ------------------------------ | -------------------------------------------- |
|
||||
| 全部成功 | 3 DB,均 delete success | `success: true, count: 3` |
|
||||
| 部分 fallback 成功 | 2 success + 1 blocked→clear OK | `success: true, count: 3` |
|
||||
| 部分失败 | 2 success + 1 error | `success: false, count: 2, error 含失败库名` |
|
||||
| 空库列表 | `databases()` 返回 `[]` | `success: true, count: 0` |
|
||||
|
||||
#### 涉及文件
|
||||
|
||||
- `src/utils/__tests__/storageCleaner.test.ts`
|
||||
- 扩展 `createIndexedDBMock` 支持 per-db 不同 `deleteBehavior`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — 可选优化(P3)
|
||||
|
||||
### 3.1 超时常量提升到模块级
|
||||
|
||||
```typescript
|
||||
// src/utils/storageCleaner.ts 或 src/pages/StorageCleaner/constants.ts
|
||||
const INDEXED_DB_DELETE_TIMEOUT_MS = 5000;
|
||||
const INDEXED_DB_CLEAR_STORE_TIMEOUT_MS = 5000;
|
||||
```
|
||||
|
||||
注入脚本通过闭包引用(executeScript 会序列化 func,常量需在 func 外部定义并 capture,或仍写在 func 内但从模块常量赋值)。
|
||||
|
||||
### 3.2 IndexedDB 逻辑拆分(长期)
|
||||
|
||||
将 `clearObjectStores`、`waitForDeleteDatabase` 等抽到 `src/utils/indexedDbCleaner.ts` 的纯函数,注入层只做:
|
||||
|
||||
```typescript
|
||||
async () => clearAllIndexedDBs(INDEXED_DB_DELETE_TIMEOUT_MS);
|
||||
```
|
||||
|
||||
便于单测,不依赖 `mockExecuteScriptEval` 间接执行注入函数。工作量大,建议单独 PR。
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[1.1 transaction.oncomplete] --> B[1.2 delete settle 防竞态]
|
||||
B --> C[2.4 补多 DB 测试]
|
||||
C --> D[2.1 部分成功 count]
|
||||
D --> E[2.2 统一 executeInTab]
|
||||
E --> F[2.3 错误分隔符]
|
||||
F --> G[3.x 可选重构]
|
||||
```
|
||||
|
||||
| 阶段 | 预估工作量 | 风险 |
|
||||
| ------- | ---------- | ---------------- |
|
||||
| Phase 1 | 0.5~1 天 | 低,逻辑局部 |
|
||||
| Phase 2 | 0.5~1 天 | 中,涉及类型扩展 |
|
||||
| Phase 3 | 1~2 天 | 低,可延后 |
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### A. 自动化(CI 必须通过)
|
||||
|
||||
```bash
|
||||
npm run test -- src/utils/__tests__/storageCleaner.test.ts
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
```
|
||||
|
||||
| 编号 | 标准 |
|
||||
| ---- | ------------------------------------------------------------------------------ |
|
||||
| A-1 | 全部单元测试通过,新增测试 ≥ 3(transaction 顺序、late onsuccess、多 DB 混合) |
|
||||
| A-2 | `tsc --noEmit` 无错误;若扩展 `StorageCleanResult`,所有引用处类型正确 |
|
||||
| A-3 | ESLint `--max-warnings=0` 通过 |
|
||||
|
||||
---
|
||||
|
||||
### B. 功能行为
|
||||
|
||||
| 编号 | 场景 | 期望结果 |
|
||||
| ---- | ---------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| B-1 | 单 DB,`deleteDatabase` 成功 | `indexedDB: { success: true, count: 1 }`,`overallSuccess: true` |
|
||||
| B-2 | 单 DB,`deleteDatabase` blocked,fallback clear 成功 | `success: true, count: 1`;transaction 在 `oncomplete` 后 `db.close` |
|
||||
| B-3 | 单 DB,delete 超时 5s,fallback clear 成功 | 5s 内进入 fallback;不因 late `onsuccess` 重复计数 |
|
||||
| B-4 | fallback 中某 store clear hang 5s | `success: false`,error 含 `dbName/storeName` |
|
||||
| B-5 | 3 DB:2 成功 + 1 失败 | `success: false`,`count: 2`(Phase 2.1 后),error 含失败库名与部分成功提示 |
|
||||
| B-6 | `executeScript` 注入失败 | `success: false`,**不得** `{ success: true, count: 0 }` |
|
||||
| B-7 | localStorage 成功 + cookies 失败 | `overallSuccess: false`,`result.error` 为 `Cookies: ...`(换行分隔多项失败) |
|
||||
|
||||
---
|
||||
|
||||
### C. 回归与 UI
|
||||
|
||||
| 编号 | 标准 |
|
||||
| ---- | --------------------------------------------------------------------------------------------------------- |
|
||||
| C-1 | `formatCleaningResult` 成功路径不变 |
|
||||
| C-2 | `CleaningResult` 失败时展示 `result.error`;含 `\n` 时多行可读(现有 `leading-relaxed break-all` 可接受) |
|
||||
| C-3 | `reloadAfterClean=true` 且 `overallSuccess=false` 时不刷新页面(`useStorageCleaner` 现有逻辑) |
|
||||
| C-4 | Cookie 清理:domain 前导 `.` 剥离逻辑不变 |
|
||||
|
||||
---
|
||||
|
||||
### D. 手动验收(扩展环境)
|
||||
|
||||
在 Chrome 加载 unpacked extension,选普通 HTTPS 页面:
|
||||
|
||||
| 编号 | 步骤 | 期望 |
|
||||
| ---- | ------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| D-1 | 页面写入 localStorage + IndexedDB,仅清 IndexedDB | 成功提示或明确错误;DevTools → Application → IndexedDB 数据为空或库已删 |
|
||||
| D-2 | 打开 DevTools 保持 IndexedDB 面板,执行清理 | 若 blocked,显示中文提示;fallback 成功后数据不可见 |
|
||||
| D-3 | 勾选「清理后刷新」且全部成功 | Toast「清理成功,即将刷新页面」,页面刷新 |
|
||||
| D-4 | 部分失败 | 不刷新;结果区红色展示错误详情 |
|
||||
|
||||
---
|
||||
|
||||
### E. 代码质量
|
||||
|
||||
| 编号 | 标准 |
|
||||
| ---- | ------------------------------------------------------------------ |
|
||||
| E-1 | 注入脚本内无重复 `settled` / timeout 逻辑(删除与 clear 各自封装) |
|
||||
| E-2 | 错误文案仍为中文,含库名/store 名 |
|
||||
| E-3 | 无 `any`(测试文件除外) |
|
||||
| E-4 | Phase 1 合并后,P1 项在 PR 描述中标注「已修复」并附测试名 |
|
||||
|
||||
---
|
||||
|
||||
## PR 检查清单
|
||||
|
||||
```markdown
|
||||
## 修复内容
|
||||
|
||||
- [ ] P1: transaction.oncomplete 后再 db.close
|
||||
- [ ] P1: deleteDatabase settle 防竞态
|
||||
- [ ] P2: 部分成功保留 count(可选)
|
||||
- [ ] P2: 多 DB 混合测试
|
||||
- [ ] P2: 错误信息 `\n` 分隔(可选)
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] npm run test / typecheck / lint 通过
|
||||
- [ ] 新增测试覆盖 B-2、B-3、B-5
|
||||
- [ ] 手动 D-1 ~ D-4 至少测 D-1、D-3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 风险与边界说明
|
||||
|
||||
1. **fallback 清空 ≠ 删除库**:blocked 时只清 object store,库结构仍在;成功 `count` 表示「有效清理动作完成」,需在 UI/文档中说明(可选文案:「数据已清空,数据库结构可能仍存在」)。
|
||||
2. **timeout 延迟 fallback**:100~200ms 为经验值,无法完全消除竞态,只能降低概率;完全消除需浏览器不支持 abort delete 的前提下接受 best-effort。
|
||||
3. **类型扩展**:`StorageCleanResult` 加可选 `count` 为向后兼容;消费方用 `'count' in result && result.count` 判断即可。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件索引
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------------------------------- | -------------------------------------------- |
|
||||
| `src/utils/storageCleaner.ts` | 核心清理逻辑 |
|
||||
| `src/utils/__tests__/storageCleaner.test.ts` | 单元测试 |
|
||||
| `src/types/storage.d.ts` | `StorageCleanResult` / `CleaningResult` 类型 |
|
||||
| `src/pages/StorageCleaner/constants.ts` | 选项标签与键名 |
|
||||
| `src/pages/StorageCleaner/useStorageCleaner.ts` | 清理流程编排 |
|
||||
| `src/pages/StorageCleaner/components/CleaningResult.tsx` | 结果展示 UI |
|
||||
@@ -0,0 +1,48 @@
|
||||
# 测试数据生成器 - 功能设计文档
|
||||
|
||||
> 版本: v1.0
|
||||
> 创建时间: 2024-01-20
|
||||
> 状态: 已实现(见 `src/pages/TestDataGenerator/`、`src/lib/generators/`、`src/workers/generator.worker.ts`)
|
||||
|
||||
## 产品定位
|
||||
|
||||
**轻量级可视化测试数据生成器**
|
||||
|
||||
- 纯前端工具,无需后端
|
||||
- 可视化配置,无需编码
|
||||
- 本地生成,数据安全
|
||||
|
||||
## 目录
|
||||
|
||||
- [核心功能](./core-features.md)
|
||||
- [生成器库](./generators.md)
|
||||
- [规则管理](./rule-management.md)
|
||||
- [界面设计](./ui-design.md)
|
||||
- [技术实现](./technical-implementation.md)
|
||||
|
||||
## 当前实现入口
|
||||
|
||||
- 页面入口:`src/pages/TestDataGenerator/index.tsx`
|
||||
- Worker:`src/workers/generator.worker.ts`
|
||||
- 生成器库:`src/lib/generators/`
|
||||
- 规则存储:`src/utils/ruleStorage.ts`
|
||||
- 导出工具:`src/utils/dataExporter.ts`
|
||||
|
||||
实现约束与任务完成状态记录在 [TASKS.md](./TASKS.md)。
|
||||
|
||||
## 开发者注意事项(与源码同步)
|
||||
|
||||
以下行为以 `src/` 源码为准;设计文档中的旧版 class API、`metadata`/`options` 嵌套结构已废弃。
|
||||
|
||||
### Worker 任务 ID(`generationId`)
|
||||
|
||||
`useGenerator` 每次调用 `generate()` 递增 `generationIdRef`,经 `start` 消息传入 Worker。Worker 所有响应(`progress` / `complete` / `error`)均携带同一 `generationId`。
|
||||
|
||||
- **取消**:`cancel()` 先递增 ID 再发送 `cancel`,使进行中的 Worker 响应被主线程忽略;Worker 每生成 100 行让出事件循环以处理 cancel。
|
||||
- **快速重试**:新任务 ID 大于旧响应时,旧消息被丢弃,避免 UI 状态错乱。
|
||||
|
||||
类型见 `WorkerRequestMessage` / `WorkerResponseMessage`(`src/types/testDataGenerator.ts`)。
|
||||
|
||||
### 规则存储写入失败
|
||||
|
||||
`ruleStorage.save()` / `update()` 在 `localStorage.setItem` 失败时返回 `null`(不部分提交)。页面层(如 `FieldList.tsx`)仅在返回值非空时 Toast 成功。详见 [rule-management.md § 存储机制](./rule-management.md#存储机制) 与 `src/utils/README.md`。
|
||||
@@ -0,0 +1,384 @@
|
||||
# 测试数据生成器 - 实现任务列表
|
||||
|
||||
> 创建时间: 2026-06-06
|
||||
> 状态: ✅ 已完成
|
||||
|
||||
## 总体进度
|
||||
|
||||
| 子功能 | 状态 | 预估工时 |
|
||||
| -------------------------- | --------- | --------- |
|
||||
| 1. 类型定义与项目配置 | ✅ 已完成 | 0.5h |
|
||||
| 2. 生成器库 | ✅ 已完成 | 4h |
|
||||
| 3. Web Worker 数据生成引擎 | ✅ 已完成 | 2h |
|
||||
| 4. 规则存储与管理 | ✅ 已完成 | 2h |
|
||||
| 5. 数据导出功能 | ✅ 已完成 | 1h |
|
||||
| 6. UI 组件开发 | ✅ 已完成 | 6h |
|
||||
| 7. 页面集成与注册 | ✅ 已完成 | 1h |
|
||||
| 8. 测试与优化 | ✅ 已完成 | 2h |
|
||||
| **总计** | | **18.5h** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 类型定义与项目配置
|
||||
|
||||
**目标**: 创建所有必要的类型定义文件,配置项目注册新功能
|
||||
|
||||
### 任务清单
|
||||
|
||||
- [x] 1.1 创建 `src/types/testDataGenerator.ts` 类型定义文件
|
||||
- [x] 定义 `FieldConfig` 接口
|
||||
- [x] 定义 `DataRule` 接口
|
||||
- [x] 定义 `GeneratorDefinition` 接口
|
||||
- [x] 定义 `GeneratorParam` 接口
|
||||
- [x] 定义 `GenerateResult` 接口
|
||||
- [x] 定义 `ExportFile` 接口
|
||||
|
||||
- [x] 1.2 更新 `src/types/storage.d.ts`
|
||||
- [x] 在 `PageType` 中添加 `'testDataGenerator'`
|
||||
- [x] 在 `StorageSchema` 中添加测试数据生成器相关配置(如需要)
|
||||
|
||||
- [x] 1.3 更新 `src/config/features.tsx`
|
||||
- [x] 导入 TestDataGenerator 页面组件
|
||||
- [x] 添加新功能配置到 `FEATURES` 数组
|
||||
- [x] 选择合适的图标(如 `Database` 或 `FileSpreadsheet`)
|
||||
|
||||
- [x] 1.4 更新国际化配置
|
||||
- [x] 添加 `testDataGenerator_title` 翻译
|
||||
- [x] 添加 `testDataGenerator_description` 翻译
|
||||
|
||||
---
|
||||
|
||||
## 2. 生成器库
|
||||
|
||||
**目标**: 实现 21 个数据生成器,支持多种生成策略
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 2.1 生成器基础框架
|
||||
|
||||
- [x] 2.1.1 创建 `src/lib/generators/types.ts`
|
||||
- [x] 定义生成器内部类型(与 `testDataGenerator.ts` 分离)
|
||||
|
||||
- [x] 2.1.2 创建 `src/lib/generators/index.ts`
|
||||
- [x] 导出所有生成器
|
||||
- [x] 导出 `generatorCategories` 配置
|
||||
|
||||
#### 2.2 个人信息生成器 (6个)
|
||||
|
||||
- [x] 2.2.1 创建 `src/lib/generators/personal.ts`
|
||||
- [x] 实现 `chineseName` 生成器
|
||||
- [x] `generate()` 方法
|
||||
- [x] `generateAtIndex()` 方法
|
||||
- [x] 实现 `email` 生成器
|
||||
- [x] `generate()` 方法
|
||||
- [x] `generateAtIndex()` 方法
|
||||
- [x] 实现 `chinesePhone` 生成器
|
||||
- [x] `generate()` 方法
|
||||
- [x] `generateAtIndex()` 方法
|
||||
- [x] 实现 `idCard` 生成器
|
||||
- [x] `generate()` 方法
|
||||
- [x] `generateAtIndex()` 方法
|
||||
- [x] 实现 `chineseAddress` 生成器
|
||||
- [x] `generate()` 方法
|
||||
- [x] `generateAtIndex()` 方法
|
||||
- [x] 实现 `age` 生成器
|
||||
- [x] `generate()` 方法(支持 realistic/uniform/demographic 策略)
|
||||
- [x] `generateAtIndex()` 方法
|
||||
- [x] 实现 `normalRandom()` 辅助函数
|
||||
- [x] 实现 `demographicRandom()` 辅助函数
|
||||
|
||||
#### 2.3 业务数据生成器 (8个)
|
||||
|
||||
- [x] 2.3.1 创建 `src/lib/generators/business.ts`
|
||||
- [x] 实现 `orderId` 生成器
|
||||
- [x] 实现 `price` 生成器
|
||||
- [x] 实现 `generatePsychologicalPrice()` 辅助函数
|
||||
- [x] 实现 `generateRealisticPrice()` 辅助函数
|
||||
- [x] 实现 `date` 生成器
|
||||
- [x] 实现 `formatDate()` 辅助函数
|
||||
- [x] 实现 `status` 生成器
|
||||
- [x] 实现 `quantity` 生成器
|
||||
- [x] 实现 `poissonRandom()` 辅助函数
|
||||
- [x] 实现 `rating` 生成器
|
||||
- [x] 实现 `skewedRandom()` 辅助函数
|
||||
- [x] 实现 `discount` 生成器
|
||||
- [x] 实现 `generatePsychologicalDiscount()` 辅助函数
|
||||
- [x] 实现 `stock` 生成器
|
||||
- [x] 实现 `exponentialRandom()` 辅助函数
|
||||
|
||||
#### 2.4 技术数据生成器 (3个)
|
||||
|
||||
- [x] 2.4.1 创建 `src/lib/generators/technical.ts`
|
||||
- [x] 实现 `uuid` 生成器
|
||||
- [x] 实现 `ipv4` 生成器
|
||||
- [x] 实现 `url` 生成器
|
||||
|
||||
#### 2.5 基础类型生成器 (4个)
|
||||
|
||||
- [x] 2.5.1 创建 `src/lib/generators/basic.ts`
|
||||
- [x] 实现 `randomInt` 生成器
|
||||
- [x] 实现 `randomFloat` 生成器
|
||||
- [x] 实现 `randomString` 生成器
|
||||
- [x] 实现 `fromList` 生成器
|
||||
|
||||
---
|
||||
|
||||
## 3. Web Worker 数据生成引擎
|
||||
|
||||
**目标**: 实现后台数据生成,支持进度回调和取消
|
||||
|
||||
### 任务清单
|
||||
|
||||
- [x] 3.1 创建 `src/workers/generator.worker.ts`
|
||||
- [x] 实现 `self.onmessage` 处理器
|
||||
- [x] 验证生成器是否存在
|
||||
- [x] 实现数据生成循环
|
||||
- [x] 实现空值率控制逻辑
|
||||
- [x] 实现唯一性约束逻辑
|
||||
- [x] 小数据量:随机生成 + 重试(100次上限)
|
||||
- [x] 大数据量:索引生成策略
|
||||
- [x] 实现进度回调(每1000条)
|
||||
- [x] 实现完成回调(包含统计数据)
|
||||
- [x] 实现错误回调
|
||||
|
||||
- [x] 3.2 创建 `src/pages/TestDataGenerator/hooks/useGenerator.ts`
|
||||
- [x] 实现 Worker 创建和销毁
|
||||
- [x] 实现 `generate()` 方法
|
||||
- [x] 实现 `terminate()` 方法
|
||||
- [x] 管理进度状态
|
||||
- [x] 管理生成结果状态
|
||||
|
||||
---
|
||||
|
||||
## 4. 规则存储与管理
|
||||
|
||||
**目标**: 实现规则的持久化存储和管理功能
|
||||
|
||||
### 任务清单
|
||||
|
||||
- [x] 4.1 创建 `src/utils/ruleStorage.ts`
|
||||
- [x] 定义 `STORAGE_KEY` 常量
|
||||
- [x] 定义 `MAX_RULES = 20` 常量
|
||||
- [x] 实现 `getAll()` 方法
|
||||
- [x] 实现 `getById()` 方法
|
||||
- [x] 实现 `getCount()` 方法
|
||||
- [x] 实现 `isMaxReached()` 方法
|
||||
- [x] 实现 `save()` 方法(含数量限制检查)
|
||||
- [x] 实现 `update()` 方法
|
||||
- [x] 实现 `delete()` 方法
|
||||
- [x] 实现 `duplicate()` 方法
|
||||
- [x] 实现 `recordUse()` 方法
|
||||
- [x] 实现 `search()` 方法
|
||||
- [x] 实现 `getRecent()` 方法
|
||||
- [x] 实现 `export()` 方法
|
||||
- [x] 实现 `import()` 方法(含格式验证)
|
||||
- [x] 实现 `clear()` 方法
|
||||
- [x] 实现 `validateRule()` 私有方法
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据导出功能
|
||||
|
||||
**目标**: 实现 JSON 和 CSV 格式的数据导出
|
||||
|
||||
### 任务清单
|
||||
|
||||
- [x] 5.1 创建 `src/utils/dataExporter.ts`
|
||||
- [x] 实现 `toJSON()` 静态方法
|
||||
- [x] 实现 `toCSV()` 静态方法
|
||||
- [x] 实现 `escapeCSV()` 私有方法
|
||||
- [x] 实现 `exportByFormat()` 静态方法
|
||||
- [x] 实现 `toMultipleFiles()` 静态方法
|
||||
- [x] 实现 `toSingleFile()` 静态方法
|
||||
- [x] 实现 `getMimeType()` 私有方法
|
||||
- [x] 实现 `download()` 静态方法
|
||||
- [x] 实现 `downloadMultiple()` 静态方法
|
||||
|
||||
---
|
||||
|
||||
## 6. UI 组件开发
|
||||
|
||||
**目标**: 实现所有界面组件,遵循视觉规范
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 6.1 页面组件
|
||||
|
||||
- [x] 6.1.1 创建 `src/pages/TestDataGenerator/index.tsx` 主页面
|
||||
- [x] 实现左右分栏布局(60% / 40%)
|
||||
- [x] 集成所有子组件
|
||||
- [x] 管理页面状态
|
||||
|
||||
#### 6.2 字段管理组件
|
||||
|
||||
- [x] 6.2.1 创建 `src/pages/TestDataGenerator/components/FieldList.tsx`
|
||||
- [x] 实现字段列表展示
|
||||
- [x] 实现添加字段按钮
|
||||
- [x] 实现字段拖拽排序(可选,使用上移/下移按钮)
|
||||
|
||||
- [x] 6.2.2 创建 `src/pages/TestDataGenerator/components/FieldItem.tsx`
|
||||
- [x] 实现单个字段项展示
|
||||
- [x] 实现字段名编辑
|
||||
- [x] 实现生成器切换下拉框
|
||||
- [x] 实现配置按钮
|
||||
- [x] 实现删除按钮
|
||||
- [x] 实现上移/下移按钮
|
||||
|
||||
- [x] 6.2.3 创建 `src/pages/TestDataGenerator/components/FieldEditor.tsx`
|
||||
- [x] 实现基础配置区(字段名、描述)
|
||||
- [x] 实现必填/选填切换
|
||||
- [x] 实现空值率配置(仅选填时显示)
|
||||
- [x] 滑块组件
|
||||
- [x] 预设按钮(低/中/高)
|
||||
- [x] 实现唯一性约束开关
|
||||
|
||||
#### 6.3 生成器相关组件
|
||||
|
||||
- [x] 6.3.1 创建 `src/pages/TestDataGenerator/components/GeneratorSelector.tsx`
|
||||
- [x] 实现分类展示(个人信息/业务数据/技术数据/基础类型)
|
||||
- [x] 实现搜索功能
|
||||
- [x] 实现生成器选择
|
||||
|
||||
- [x] 6.3.2 创建 `src/pages/TestDataGenerator/components/GeneratorConfig.tsx`
|
||||
- [x] 实现参数配置表单
|
||||
- [x] 支持不同参数类型(string/number/boolean/select/array)
|
||||
- [x] 实时预览更新
|
||||
|
||||
#### 6.4 数据预览组件
|
||||
|
||||
- [x] 6.4.1 创建 `src/pages/TestDataGenerator/components/DataPreview.tsx`
|
||||
- [x] 实现 JSON 格式预览
|
||||
- [x] 实现 CSV 格式预览
|
||||
- [x] 实现格式切换按钮
|
||||
- [x] 实现数据分页
|
||||
- [x] 实现复制功能
|
||||
|
||||
#### 6.5 生成选项组件
|
||||
|
||||
- [x] 6.5.1 创建 `src/pages/TestDataGenerator/components/GenerateOptions.tsx`
|
||||
- [x] 实现生成数量配置(预设 + 自定义)
|
||||
- [x] 实现数据格式选择(JSON/CSV)
|
||||
- [x] 实现默认空值率配置
|
||||
|
||||
- [x] 6.5.2 创建 `src/pages/TestDataGenerator/components/GenerateButton.tsx`
|
||||
- [x] 实现生成按钮
|
||||
- [x] 实现加载状态
|
||||
- [x] 实现进度条显示
|
||||
|
||||
#### 6.6 导出组件
|
||||
|
||||
- [x] 6.6.1 创建 `src/pages/TestDataGenerator/components/ExportPanel.tsx`
|
||||
- [x] 实现导出选项(复制/下载 JSON/下载 CSV)
|
||||
- [x] 实现下载逻辑
|
||||
|
||||
#### 6.7 结果展示组件
|
||||
|
||||
- [x] 6.7.1 创建 `src/pages/TestDataGenerator/components/ResultPanel.tsx`
|
||||
- [x] 实现成功状态展示
|
||||
- [x] 实现警告状态展示(部分字段失败)
|
||||
- [x] 实现错误状态展示
|
||||
- [x] 实现数据统计展示
|
||||
|
||||
#### 6.8 规则管理组件
|
||||
|
||||
- [x] 6.8.1 创建 `src/pages/TestDataGenerator/components/RuleManager.tsx`
|
||||
- [x] 实现规则列表展示
|
||||
- [x] 实现搜索功能
|
||||
- [x] 实现加载/编辑/删除/复制按钮
|
||||
- [x] 实现导入/导出功能
|
||||
- [x] 实现规则数量显示(已保存 X/20 条)
|
||||
|
||||
---
|
||||
|
||||
## 7. 页面集成与注册
|
||||
|
||||
**目标**: 将新功能集成到应用中
|
||||
|
||||
### 任务清单
|
||||
|
||||
- [x] 7.1 更新路由配置
|
||||
- [x] 确保 `RouterProvider` 支持新页面类型
|
||||
|
||||
- [x] 7.2 更新功能注册
|
||||
- [x] 验证 `features.tsx` 配置正确
|
||||
- [x] 验证图标显示正常
|
||||
|
||||
- [x] 7.3 更新仪表盘
|
||||
- [x] 在 Dashboard 中显示新工具卡片
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试与优化
|
||||
|
||||
**目标**: 确保功能稳定性和性能
|
||||
|
||||
### 任务清单
|
||||
|
||||
#### 8.1 单元测试
|
||||
|
||||
- [x] 8.1.1 创建生成器测试
|
||||
- [x] `src/lib/generators/__tests__/personal.test.ts`
|
||||
- [x] `src/lib/generators/__tests__/business.test.ts`
|
||||
- [x] `src/lib/generators/__tests__/technical.test.ts`
|
||||
- [x] `src/lib/generators/__tests__/basic.test.ts`
|
||||
|
||||
- [x] 8.1.2 创建工具函数测试
|
||||
- [x] `src/utils/__tests__/ruleStorage.test.ts`
|
||||
- [x] `src/utils/__tests__/dataExporter.test.ts`
|
||||
|
||||
#### 8.2 集成测试
|
||||
|
||||
- [x] 8.2.1 创建页面测试
|
||||
- [x] `src/pages/TestDataGenerator/__tests__/TestDataGenerator.test.tsx`
|
||||
|
||||
#### 8.3 性能优化
|
||||
|
||||
- [x] 8.3.1 验证 Web Worker 性能
|
||||
- [x] 测试 10000+ 条数据生成
|
||||
- [x] 验证 UI 不阻塞
|
||||
|
||||
- [x] 8.3.2 验证内存使用
|
||||
- [x] 检查大数据量时的内存占用
|
||||
- [x] 确保无内存泄漏
|
||||
|
||||
#### 8.4 代码质量
|
||||
|
||||
- [x] 8.4.1 代码审查
|
||||
- [x] 遵循项目代码规范
|
||||
- [x] 遵循视觉规范文档
|
||||
|
||||
- [x] 8.4.2 文档更新
|
||||
- [x] 更新 AGENTS.md(如需要)
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
```
|
||||
1. 类型定义与项目配置
|
||||
↓
|
||||
2. 生成器库
|
||||
↓
|
||||
3. Web Worker 数据生成引擎 (依赖 2)
|
||||
↓
|
||||
4. 规则存储与管理
|
||||
↓
|
||||
5. 数据导出功能
|
||||
↓
|
||||
6. UI 组件开发 (依赖 1, 2, 3, 4, 5)
|
||||
↓
|
||||
7. 页面集成与注册 (依赖 6)
|
||||
↓
|
||||
8. 测试与优化 (依赖 7)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **遵循视觉规范**: 所有 UI 组件必须遵循 `docs/VISUAL_STYLE_GUIDE.md`
|
||||
2. **使用现有组件**: 优先使用 `src/components/ui/` 中的 shadcn/ui 组件
|
||||
3. **TypeScript 严格模式**: 确保类型安全
|
||||
4. **错误处理**: 使用 `sonner` 库进行 Toast 提示
|
||||
5. **性能考虑**: 大数据量生成必须使用 Web Worker
|
||||
6. **响应式设计**: 支持桌面端、平板端、移动端
|
||||
@@ -0,0 +1,238 @@
|
||||
# 核心功能
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能模块 | 功能点 | 说明 | 优先级 |
|
||||
| -------------- | ------------- | ------------------ | ------ |
|
||||
| **字段配置** | 添加字段 | 可视化添加数据字段 | P0 |
|
||||
| | 删除字段 | 删除不需要的字段 | P0 |
|
||||
| | 字段排序 | 拖拽调整字段顺序 | P1 |
|
||||
| | 字段命名 | 自定义字段名称 | P0 |
|
||||
| | 必填/选填设置 | 控制字段是否必填 | P0 |
|
||||
| | 空值率配置 | 选填字段空值概率 | P1 |
|
||||
| **生成器系统** | 生成器选择 | 从生成器库中选择 | P0 |
|
||||
| | 参数配置 | 配置生成器参数 | P0 |
|
||||
| | 实时预览 | 配置时查看生成结果 | P1 |
|
||||
| **数据生成** | 批量生成 | 支持 10000+ 条数据 | P0 |
|
||||
| | 唯一性约束 | 保证字段值唯一 | P1 |
|
||||
| | 组合策略 | 自动选择生成策略 | P1 |
|
||||
| | 数据预览 | 生成前预览数据 | P1 |
|
||||
| **数据导出** | JSON 导出 | 导出为 JSON 格式 | P0 |
|
||||
| | CSV 导出 | 导出为 CSV 格式 | P0 |
|
||||
|
||||
## 功能详情
|
||||
|
||||
### 1. 字段配置
|
||||
|
||||
#### 添加字段
|
||||
|
||||
- 用户点击"添加字段"按钮
|
||||
- 弹出生成器选择器
|
||||
- 选择生成器后,自动创建新字段
|
||||
- 字段默认名称为生成器名称
|
||||
|
||||
#### 删除字段
|
||||
|
||||
- 每个字段右侧有删除按钮
|
||||
- 点击后弹出确认对话框
|
||||
- 确认后删除该字段
|
||||
|
||||
#### 字段排序
|
||||
|
||||
- 支持拖拽排序
|
||||
- 支持上移/下移按钮
|
||||
- 排序后预览数据同步更新
|
||||
|
||||
#### 字段命名
|
||||
|
||||
- 字段名称可自定义
|
||||
- 支持中英文命名
|
||||
- 同一规则内字段名不可重复
|
||||
|
||||
#### 必填/选填设置
|
||||
|
||||
- 每个字段可设置为"必填"或"选填"
|
||||
- **必填字段**: 100% 生成数据
|
||||
- **选填字段**: 可配置空值概率(0-100%)
|
||||
- 默认所有字段为必填
|
||||
|
||||
#### 空值率配置
|
||||
|
||||
- 选填字段可单独设置空值率
|
||||
- 提供预设值:低(20%)、中(50%)、高(80%)
|
||||
- 支持滑块精细调节(0-100%)
|
||||
- 未单独设置的选填字段使用全局默认空值率
|
||||
|
||||
---
|
||||
|
||||
### 2. 生成器系统
|
||||
|
||||
#### 生成器选择
|
||||
|
||||
- 以分类方式展示生成器
|
||||
- 支持搜索生成器
|
||||
- 点击选择后添加到字段配置
|
||||
|
||||
#### 价格生成器(业务数据)
|
||||
|
||||
生成真实的价格数据,支持多种策略:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| -------- | ------------------------------ | ------------ |
|
||||
| 真实分布 | 对数正态分布,模拟真实商品价格 | 通用电商场景 |
|
||||
| 均匀随机 | 所有价格等概率 | 测试数据 |
|
||||
| 心理定价 | .99/.98/.95 结尾 | 营销场景 |
|
||||
|
||||
**参数配置**:
|
||||
|
||||
- 最低价/最高价:控制价格区间
|
||||
- 小数位数:0(整数)、1、2 位
|
||||
- 生成策略:选择分布类型
|
||||
|
||||
#### 年龄生成器(个人信息)
|
||||
|
||||
生成真实分布的年龄数据:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| -------- | ---------------------------- | ------------ |
|
||||
| 真实分布 | 正态分布,均值 35,标准差 10 | 通用用户数据 |
|
||||
| 人口比例 | 按中国人口年龄比例分布 | 市场调研数据 |
|
||||
| 均匀随机 | 所有年龄等概率 | 测试数据 |
|
||||
|
||||
**参数配置**:
|
||||
|
||||
- 最小年龄/最大年龄:控制年龄区间
|
||||
- 生成策略:选择分布类型
|
||||
|
||||
#### 数量生成器(业务数据)
|
||||
|
||||
生成真实分布的数量数据:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| -------- | --------------------------- | -------- |
|
||||
| 真实分布 | 泊松分布,大多数购买 1-3 件 | 零售订单 |
|
||||
| 批发模式 | 10-100 之间均匀分布 | 批发订单 |
|
||||
| 均匀随机 | 所有数量等概率 | 测试数据 |
|
||||
|
||||
**参数配置**:
|
||||
|
||||
- 最小值/最大值:控制数量区间
|
||||
- 生成策略:选择分布类型
|
||||
|
||||
#### 评分生成器(业务数据)
|
||||
|
||||
生成真实分布的评分数据:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| -------- | ------------------ | -------- |
|
||||
| 真实分布 | 偏态分布,偏向高分 | 电商平台 |
|
||||
| 严格评价 | 偏态分布,偏向低分 | 严格评审 |
|
||||
| 均匀随机 | 所有评分等概率 | 测试数据 |
|
||||
|
||||
**参数配置**:
|
||||
|
||||
- 最低分/最高分:控制评分区间
|
||||
- 小数位数:0、1 位
|
||||
- 生成策略:选择分布类型
|
||||
|
||||
#### 折扣生成器(业务数据)
|
||||
|
||||
生成真实分布的折扣数据:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| -------- | ------------------------ | -------- |
|
||||
| 心理定价 | 常见折扣点(8折、9折等) | 电商促销 |
|
||||
| 清仓模式 | 大折扣区间(3-7折) | 清仓处理 |
|
||||
| 均匀随机 | 所有折扣等概率 | 测试数据 |
|
||||
|
||||
**参数配置**:
|
||||
|
||||
- 最低折扣/最高折扣:控制折扣区间(0.1-0.9)
|
||||
- 生成策略:选择分布类型
|
||||
|
||||
#### 库存生成器(业务数据)
|
||||
|
||||
生成真实分布的库存数据:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| -------- | ------------------------ | -------- |
|
||||
| 真实分布 | 指数分布,大多数库存较少 | 通用商品 |
|
||||
| 热销商品 | 低库存区间(0-50) | 热销商品 |
|
||||
| 均匀随机 | 所有库存等概率 | 测试数据 |
|
||||
|
||||
**参数配置**:
|
||||
|
||||
- 最小库存/最大库存:控制库存区间
|
||||
- 生成策略:选择分布类型
|
||||
|
||||
#### 参数配置
|
||||
|
||||
- 每个生成器有对应的参数配置界面
|
||||
- 必填参数标记星号
|
||||
- 修改参数后实时预览更新
|
||||
|
||||
#### 实时预览
|
||||
|
||||
- 配置参数时,右侧预览区实时更新
|
||||
- 显示 5-10 条示例数据
|
||||
- 帮助用户确认配置是否正确
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据生成
|
||||
|
||||
#### 批量生成
|
||||
|
||||
- 支持配置生成数量
|
||||
- 使用 Web Worker 后台生成
|
||||
- 支持 10000+ 条数据
|
||||
- 生成过程中显示进度条
|
||||
|
||||
#### 唯一性约束
|
||||
|
||||
- 每个字段可单独设置唯一性约束
|
||||
- 启用后,该字段生成的值不会重复
|
||||
- 如果无法生成唯一值,使用重试机制
|
||||
|
||||
#### 组合策略(唯一性生成)
|
||||
|
||||
系统根据数据量自动选择最优生成策略:
|
||||
|
||||
| 数据量 | 策略 | 说明 |
|
||||
| --------- | --------------- | ---------------------- |
|
||||
| ≤ 1000 条 | 随机生成 + 重试 | 保证随机性,性能可接受 |
|
||||
| > 1000 条 | 索引生成 | 高性能,保证唯一性 |
|
||||
|
||||
**实现方式**:
|
||||
|
||||
- 生成器提供两个方法:`generate()` 和 `generateAtIndex()`
|
||||
- `generate()`: 随机生成,用于小数据量或非唯一性字段
|
||||
- `generateAtIndex()`: 索引生成,用于大数据量唯一性字段
|
||||
- 系统根据配置自动选择合适的方法
|
||||
|
||||
**示例**:
|
||||
|
||||
- 手机号字段:`generateAtIndex(params, index)` 使用 index 生成唯一手机号
|
||||
- 姓名字段:`generateAtIndex(params, index)` 使用 index 组合姓和名
|
||||
|
||||
#### 数据预览
|
||||
|
||||
- 生成前可预览数据
|
||||
- 显示前 10 条数据
|
||||
- 支持分页查看更多数据
|
||||
|
||||
---
|
||||
|
||||
### 4. 数据导出
|
||||
|
||||
#### JSON 导出
|
||||
|
||||
- 导出为标准 JSON 格式
|
||||
- 支持压缩和格式化两种模式
|
||||
- 包含字段名和数据
|
||||
|
||||
#### CSV 导出
|
||||
|
||||
- 导出为 CSV 格式
|
||||
- 自动处理逗号转义
|
||||
- 支持中文编码 (UTF-8 with BOM)
|
||||
@@ -0,0 +1,702 @@
|
||||
# 生成器库
|
||||
|
||||
## 分类概览
|
||||
|
||||
| 分类 | 生成器数量 | 说明 |
|
||||
| -------- | ---------- | ------------------------------------------------ |
|
||||
| 个人信息 | 6 个 | 中文姓名、邮箱、手机号、年龄等 |
|
||||
| 业务数据 | 8 个 | 订单号、价格、日期、状态、数量、评分、折扣、库存 |
|
||||
| 技术数据 | 3 个 | UUID、IP地址、URL |
|
||||
| 基础类型 | 4 个 | 整数、浮点数、字符串等 |
|
||||
| **总计** | **21 个** | |
|
||||
|
||||
---
|
||||
|
||||
## 个人信息生成器
|
||||
|
||||
### 1. 中文姓名 (chineseName)
|
||||
|
||||
**说明**: 生成中文姓名,如张三、李四
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| --------------- | ---------------------------- | ---- | ------------------ | ------------ |
|
||||
| surnamePool | string \| string[] | 否 | 百家姓前100个 | 姓氏池 |
|
||||
| givenNameLength | { min: number, max: number } | 否 | { min: 1, max: 2 } | 名字长度范围 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "name",
|
||||
"generator": "chineseName",
|
||||
"params": {
|
||||
"surnamePool": ["张", "王", "李", "赵"],
|
||||
"givenNameLength": { "min": 1, "max": 2 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 张伟, 李娜, 王强, 赵敏
|
||||
|
||||
---
|
||||
|
||||
### 2. 邮箱地址 (email)
|
||||
|
||||
**说明**: 生成邮箱地址
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| --------- | -------------------- | ---- | ------------------------------------------------------------ | ---------- |
|
||||
| domains | string[] | 否 | ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com'] | 域名列表 |
|
||||
| nameStyle | 'pinyin' \| 'random' | 否 | 'pinyin' | 用户名风格 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "email",
|
||||
"generator": "email",
|
||||
"params": {
|
||||
"domains": ["qq.com", "163.com"],
|
||||
"nameStyle": "pinyin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: zhangwei@qq.com, lina@163.com
|
||||
|
||||
---
|
||||
|
||||
### 3. 中国手机号 (chinesePhone)
|
||||
|
||||
**说明**: 生成 11 位中国手机号
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | -------- | ---- | -------------- | ---------- |
|
||||
| prefix | string[] | 否 | 常见手机号前缀 | 手机号前缀 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "phone",
|
||||
"generator": "chinesePhone",
|
||||
"params": {
|
||||
"prefix": ["138", "139", "150", "151"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 13812345678, 15087654321
|
||||
|
||||
---
|
||||
|
||||
### 4. 身份证号 (idCard)
|
||||
|
||||
**说明**: 生成 18 位中国身份证号
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | ------ | ---- | ------ | ------ |
|
||||
| region | string | 否 | 随机 | 地区码 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "idCard",
|
||||
"generator": "idCard",
|
||||
"params": {
|
||||
"region": "110101"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 110101199001011234
|
||||
|
||||
---
|
||||
|
||||
### 5. 中文地址 (chineseAddress)
|
||||
|
||||
**说明**: 生成中国地址
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | ------------------------------ | ---- | ------ | -------- |
|
||||
| level | 'full' \| 'province' \| 'city' | 否 | 'full' | 详细程度 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "address",
|
||||
"generator": "chineseAddress",
|
||||
"params": {
|
||||
"level": "full"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 北京市朝阳区建国路88号
|
||||
|
||||
---
|
||||
|
||||
### 6. 年龄 (age)
|
||||
|
||||
**说明**: 生成真实分布的年龄数据
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ----------------------------------------- | ---- | ----------- | -------- |
|
||||
| min | number | 否 | 1 | 最小年龄 |
|
||||
| max | number | 否 | 100 | 最大年龄 |
|
||||
| strategy | 'realistic' \| 'demographic' \| 'uniform' | 否 | 'realistic' | 生成策略 |
|
||||
|
||||
**策略说明**:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| ----------- | ---------------------------- | ------------ |
|
||||
| realistic | 正态分布,均值 35,标准差 10 | 通用用户数据 |
|
||||
| demographic | 按中国人口年龄比例分布 | 市场调研数据 |
|
||||
| uniform | 均匀随机 | 测试数据 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "age",
|
||||
"generator": "age",
|
||||
"params": {
|
||||
"min": 18,
|
||||
"max": 65,
|
||||
"strategy": "realistic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 28, 42, 35, 51, 23
|
||||
|
||||
---
|
||||
|
||||
## 业务数据生成器
|
||||
|
||||
### 1. 订单号 (orderId)
|
||||
|
||||
**说明**: 生成订单号
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------------ | ------- | ---- | ------ | ------------ |
|
||||
| prefix | string | 否 | 'ORD' | 前缀 |
|
||||
| dateIncluded | boolean | 否 | true | 是否包含日期 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "orderId",
|
||||
"generator": "orderId",
|
||||
"params": {
|
||||
"prefix": "ORD",
|
||||
"dateIncluded": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: ORD20240115001, ORD20240115002
|
||||
|
||||
---
|
||||
|
||||
### 2. 价格 (price)
|
||||
|
||||
**说明**: 生成真实的价格数据,支持多种分布策略
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ------------------------------------------- | ---- | ----------- | -------- |
|
||||
| min | number | 是 | 1 | 最低价 |
|
||||
| max | number | 是 | 9999 | 最高价 |
|
||||
| decimals | 0 \| 1 \| 2 | 否 | 2 | 小数位数 |
|
||||
| strategy | 'realistic' \| 'uniform' \| 'psychological' | 否 | 'realistic' | 生成策略 |
|
||||
|
||||
**生成策略说明**:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| ------------- | ------------------------------ | ------------ |
|
||||
| realistic | 对数正态分布,模拟真实商品价格 | 通用电商场景 |
|
||||
| uniform | 均匀随机,所有价格等概率 | 测试数据 |
|
||||
| psychological | 心理定价,.99/.98/.95 结尾 | 营销场景 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "price",
|
||||
"generator": "price",
|
||||
"params": {
|
||||
"min": 10,
|
||||
"max": 5000,
|
||||
"decimals": 2,
|
||||
"strategy": "realistic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**:
|
||||
|
||||
- 真实分布: 299.00, 1599.50, 49.99, 1299.00
|
||||
- 心理定价: 299.99, 1599.98, 49.95, 1299.99
|
||||
- 均匀随机: 1234.56, 5678.90, 890.12
|
||||
|
||||
---
|
||||
|
||||
### 3. 日期时间 (date)
|
||||
|
||||
**说明**: 生成日期时间
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | ------ | ---- | --------------------- | -------- |
|
||||
| format | string | 否 | 'YYYY-MM-DD HH:mm:ss' | 日期格式 |
|
||||
| min | string | 否 | '2020-01-01' | 最早日期 |
|
||||
| max | string | 否 | 当前日期 | 最晚日期 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "createdAt",
|
||||
"generator": "date",
|
||||
"params": {
|
||||
"format": "YYYY-MM-DD HH:mm:ss",
|
||||
"min": "2024-01-01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 2024-01-15 14:30:22
|
||||
|
||||
---
|
||||
|
||||
### 4. 状态 (status)
|
||||
|
||||
**说明**: 从选项中随机选择
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------- | -------- | ---- | -------- | ------------ |
|
||||
| options | string[] | 是 | - | 状态选项列表 |
|
||||
| weights | number[] | 否 | 均匀分布 | 各选项权重 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "status",
|
||||
"generator": "status",
|
||||
"params": {
|
||||
"options": ["pending", "paid", "shipped", "completed"],
|
||||
"weights": [30, 40, 20, 10]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: pending, paid, shipped, completed
|
||||
|
||||
---
|
||||
|
||||
### 5. 数量 (quantity)
|
||||
|
||||
**说明**: 生成真实分布的数量数据
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ---------------------------------- | ---- | ----------- | -------- |
|
||||
| min | number | 否 | 1 | 最小值 |
|
||||
| max | number | 否 | 100 | 最大值 |
|
||||
| strategy | 'realistic' \| 'bulk' \| 'uniform' | 否 | 'realistic' | 生成策略 |
|
||||
|
||||
**策略说明**:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| --------- | --------------------------- | -------- |
|
||||
| realistic | 泊松分布,大多数购买 1-3 件 | 零售订单 |
|
||||
| bulk | 10-100 之间均匀分布 | 批发订单 |
|
||||
| uniform | 均匀随机 | 测试数据 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "quantity",
|
||||
"generator": "quantity",
|
||||
"params": {
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"strategy": "realistic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 2, 1, 5, 3, 1
|
||||
|
||||
---
|
||||
|
||||
### 6. 评分 (rating)
|
||||
|
||||
**说明**: 生成真实分布的评分数据
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ------------------------------------ | ---- | ----------- | -------- |
|
||||
| min | number | 否 | 1 | 最低分 |
|
||||
| max | number | 否 | 5 | 最高分 |
|
||||
| decimals | number | 否 | 1 | 小数位数 |
|
||||
| strategy | 'realistic' \| 'strict' \| 'uniform' | 否 | 'realistic' | 生成策略 |
|
||||
|
||||
**策略说明**:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| --------- | ------------------ | -------- |
|
||||
| realistic | 偏态分布,偏向高分 | 电商平台 |
|
||||
| strict | 偏态分布,偏向低分 | 严格评审 |
|
||||
| uniform | 均匀随机 | 测试数据 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "rating",
|
||||
"generator": "rating",
|
||||
"params": {
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"decimals": 1,
|
||||
"strategy": "realistic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 4.5, 4.8, 3.9, 5.0, 4.2
|
||||
|
||||
---
|
||||
|
||||
### 7. 折扣 (discount)
|
||||
|
||||
**说明**: 生成真实分布的折扣数据
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ------------------------------------------- | ---- | --------------- | -------- |
|
||||
| min | number | 否 | 0.1 | 最低折扣 |
|
||||
| max | number | 否 | 0.9 | 最高折扣 |
|
||||
| strategy | 'psychological' \| 'clearance' \| 'uniform' | 否 | 'psychological' | 生成策略 |
|
||||
|
||||
**策略说明**:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| ------------- | ------------------------ | -------- |
|
||||
| psychological | 常见折扣点(8折、9折等) | 电商促销 |
|
||||
| clearance | 大折扣区间(3-7折) | 清仓处理 |
|
||||
| uniform | 均匀随机 | 测试数据 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "discount",
|
||||
"generator": "discount",
|
||||
"params": {
|
||||
"min": 0.1,
|
||||
"max": 0.9,
|
||||
"strategy": "psychological"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 0.80, 0.90, 0.85, 0.95, 0.70
|
||||
|
||||
---
|
||||
|
||||
### 8. 库存 (stock)
|
||||
|
||||
**说明**: 生成真实分布的库存数据
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | --------------------------------- | ---- | ----------- | -------- |
|
||||
| min | number | 否 | 0 | 最小库存 |
|
||||
| max | number | 否 | 1000 | 最大库存 |
|
||||
| strategy | 'realistic' \| 'hot' \| 'uniform' | 否 | 'realistic' | 生成策略 |
|
||||
|
||||
**策略说明**:
|
||||
|
||||
| 策略 | 说明 | 适用场景 |
|
||||
| --------- | ------------------------ | -------- |
|
||||
| realistic | 指数分布,大多数库存较少 | 通用商品 |
|
||||
| hot | 低库存区间(0-50) | 热销商品 |
|
||||
| uniform | 均匀随机 | 测试数据 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "stock",
|
||||
"generator": "stock",
|
||||
"params": {
|
||||
"min": 0,
|
||||
"max": 500,
|
||||
"strategy": "realistic"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 23, 8, 156, 45, 12
|
||||
|
||||
---
|
||||
|
||||
## 技术数据生成器
|
||||
|
||||
### 1. UUID (uuid)
|
||||
|
||||
**说明**: 生成 UUID
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------- | ------------ | ---- | ------ | --------- |
|
||||
| version | 'v4' \| 'v1' | 否 | 'v4' | UUID 版本 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "id",
|
||||
"generator": "uuid",
|
||||
"params": {
|
||||
"version": "v4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
---
|
||||
|
||||
### 2. IPv4 地址 (ipv4)
|
||||
|
||||
**说明**: 生成 IPv4 地址
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------- | ------- | ---- | ------ | ------------ |
|
||||
| private | boolean | 否 | false | 是否私有地址 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "ip",
|
||||
"generator": "ipv4",
|
||||
"params": {
|
||||
"private": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 192.168.1.1, 10.0.0.1
|
||||
|
||||
---
|
||||
|
||||
### 3. URL (url)
|
||||
|
||||
**说明**: 生成 URL
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ----------------- | ---- | ------------- | ---- |
|
||||
| protocol | 'http' \| 'https' | 否 | 'https' | 协议 |
|
||||
| domain | string | 否 | 'example.com' | 域名 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "website",
|
||||
"generator": "url",
|
||||
"params": {
|
||||
"protocol": "https",
|
||||
"domain": "shop.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: https://shop.com/product/123
|
||||
|
||||
---
|
||||
|
||||
## 基础类型生成器
|
||||
|
||||
### 1. 随机整数 (randomInt)
|
||||
|
||||
**说明**: 生成指定范围的随机整数
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------ | ------ | ---- | ------ | ------ |
|
||||
| min | number | 是 | - | 最小值 |
|
||||
| max | number | 是 | - | 最大值 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "age",
|
||||
"generator": "randomInt",
|
||||
"params": {
|
||||
"min": 18,
|
||||
"max": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 25, 42, 18, 60
|
||||
|
||||
---
|
||||
|
||||
### 2. 随机浮点数 (randomFloat)
|
||||
|
||||
**说明**: 生成指定范围的随机浮点数
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| -------- | ------ | ---- | ------ | -------- |
|
||||
| min | number | 是 | - | 最小值 |
|
||||
| max | number | 是 | - | 最大值 |
|
||||
| decimals | number | 否 | 2 | 小数位数 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "score",
|
||||
"generator": "randomFloat",
|
||||
"params": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"decimals": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 85.3, 42.7, 99.9
|
||||
|
||||
---
|
||||
|
||||
### 3. 随机字符串 (randomString)
|
||||
|
||||
**说明**: 生成指定长度的随机字符串
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------- | ------ | ---- | -------------- | ---------- |
|
||||
| length | number | 是 | - | 字符串长度 |
|
||||
| charset | string | 否 | 'alphanumeric' | 字符集 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "code",
|
||||
"generator": "randomString",
|
||||
"params": {
|
||||
"length": 8,
|
||||
"charset": "alphanumeric"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: aB3kL9mN, xY7zW2pQ
|
||||
|
||||
---
|
||||
|
||||
### 4. 从列表选择 (fromList)
|
||||
|
||||
**说明**: 从选项列表中随机选择
|
||||
|
||||
**参数**:
|
||||
|
||||
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|
||||
| ------- | -------- | ---- | -------- | -------- |
|
||||
| options | any[] | 是 | - | 选项列表 |
|
||||
| weights | number[] | 否 | 均匀分布 | 权重 |
|
||||
|
||||
**示例**:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "category",
|
||||
"generator": "fromList",
|
||||
"params": {
|
||||
"options": ["电子产品", "服装", "食品", "图书"],
|
||||
"weights": [40, 30, 20, 10]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**生成结果**: 电子产品, 服装, 食品
|
||||
|
||||
---
|
||||
|
||||
## 生成器选择界面
|
||||
|
||||
### 界面设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 选择生成器 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 搜索: [搜索生成器... ] │
|
||||
│ │
|
||||
│ 分类筛选 │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 全部 │ │ 个人信息 │ │ 业务数据 │ │ 技术数据 │ │ 基础类型 │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ 生成器列表 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 个人信息 │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 中文姓名 生成中文姓名,如张三 │ │ │
|
||||
│ │ │ 邮箱地址 生成邮箱地址 │ │ │
|
||||
│ │ │ 中国手机号 生成11位手机号 │ │ │
|
||||
│ │ │ 身份证号 生成18位身份证号 │ │ │
|
||||
│ │ │ 中文地址 生成中国地址 │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,422 @@
|
||||
# 规则管理
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能 | 说明 | 优先级 |
|
||||
| -------- | -------------------- | ------ |
|
||||
| 保存规则 | 将当前配置保存为模板 | P0 |
|
||||
| 规则列表 | 查看所有已保存的规则 | P0 |
|
||||
| 规则搜索 | 按关键词搜索规则 | P1 |
|
||||
| 规则编辑 | 修改已保存的规则 | P0 |
|
||||
| 规则删除 | 删除不需要的规则 | P0 |
|
||||
| 规则加载 | 一键加载规则配置 | P0 |
|
||||
| 规则复制 | 复制规则配置 | P1 |
|
||||
| 规则导出 | 导出为 JSON 文件 | P1 |
|
||||
| 规则导入 | 从 JSON 文件导入 | P1 |
|
||||
|
||||
## 规则数量限制
|
||||
|
||||
- **最大规则数量**: 20 条
|
||||
- **超出限制**: 当规则数量达到 20 条时,保存按钮置灰,提示"已达到最大规则数量"
|
||||
- **解决方式**: 用户需要删除已有规则后才能保存新规则
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
|
||||
> **与源码对齐**:类型定义见 `src/types/testDataGenerator.ts`。规则**不**持久化生成数量与导出格式(由页面 `GenerateOptions` 状态管理)。
|
||||
|
||||
### 规则模板
|
||||
|
||||
```typescript
|
||||
interface DataRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
fields: FieldConfig[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastUsedAt?: number;
|
||||
useCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 字段配置
|
||||
|
||||
```typescript
|
||||
interface FieldConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
generatorId: string; // 生成器 ID,对应 lib/generators 中的 id
|
||||
params: Record<string, unknown>;
|
||||
required: boolean;
|
||||
nullRate: number; // 空值率 0-100,仅 required=false 时生效
|
||||
unique: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 功能详情
|
||||
|
||||
### 1. 保存规则
|
||||
|
||||
**触发方式**: 用户点击"保存规则"按钮
|
||||
|
||||
**界面设计**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 保存规则 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 规则信息 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 规则名称: │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ [电商用户数据 - 测试用 ]│ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ 规则描述 (可选): │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ [用于测试用户注册功能,包含姓名、邮箱、手机号 ]│ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 规则预览 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 字段数量: 4 个 │ │
|
||||
│ │ • name (中文姓名) │ │
|
||||
│ │ • email (邮箱地址) │ │
|
||||
│ │ • phone (手机号) │ │
|
||||
│ │ • age (年龄) │ │
|
||||
│ │ │ │
|
||||
│ │ 生成数量: 100 条 │ │
|
||||
│ │ 数据格式: JSON │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [保存] [取消] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**保存逻辑**:
|
||||
|
||||
1. 验证规则名称不为空
|
||||
2. 检查规则数量是否达到上限(20 条)
|
||||
3. 若达上限,`save()` 返回 `null`,UI 应阻止或提示
|
||||
4. 生成唯一 ID,写入 `createdAt`/`updatedAt`,`useCount` 初始为 0
|
||||
5. 调用 `ruleStorage.save()`;仅当返回值非 `null` 时视为成功(`localStorage` 写入失败同样返回 `null`)
|
||||
|
||||
---
|
||||
|
||||
### 2. 规则列表
|
||||
|
||||
**界面设计**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 规则管理 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 搜索: [搜索规则名称... ] │
|
||||
│ │
|
||||
│ 规则列表 (已保存 15/20 条) │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 电商用户数据 - 测试用 │ │ │
|
||||
│ │ │ ─────────────────────────────────────────────────────── │ │ │
|
||||
│ │ │ 用于测试用户注册功能,包含姓名、邮箱、手机号 │ │ │
|
||||
│ │ │ 4 个字段 | 100 条 | JSON │ │ │
|
||||
│ │ │ 2024-01-15 14:30 创建 | 2024-01-20 10:15 最后使用 │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ [加载] [复制] [导出] [编辑] [删除] │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [导入规则] [导出全部] [清空全部] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**列表项信息**:
|
||||
|
||||
- 规则名称
|
||||
- 规则描述
|
||||
- 字段数量、生成数量、数据格式
|
||||
- 创建时间、最后使用时间
|
||||
- 操作按钮
|
||||
|
||||
---
|
||||
|
||||
### 3. 规则搜索
|
||||
|
||||
**搜索逻辑**:
|
||||
|
||||
- 支持按规则名称搜索
|
||||
- 支持按规则描述搜索
|
||||
- 搜索为模糊匹配,不区分大小写
|
||||
|
||||
**实现方式**(`src/utils/ruleStorage.ts`):
|
||||
|
||||
```typescript
|
||||
import * as ruleStorage from '@/utils/ruleStorage';
|
||||
|
||||
function searchRules(query: string): DataRule[] {
|
||||
return ruleStorage.search(query);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 规则编辑
|
||||
|
||||
**界面设计**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 编辑规则 - 电商用户数据 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 基本信息 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 规则名称: [电商用户数据 - 测试用 ]│ │
|
||||
│ │ 规则描述: [用于测试用户注册功能,包含姓名、邮箱、手机号 ]│ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 字段配置 (可直接编辑) │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 1. name [中文姓名 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ 2. email [邮箱地址 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ 3. phone [手机号 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ 4. age [年龄 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ │ │
|
||||
│ │ [+ 添加字段] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 生成选项 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 数量: [100] 格式: [JSON ▼] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [保存修改] [取消] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**编辑逻辑**:
|
||||
|
||||
1. 加载原规则配置到编辑器
|
||||
2. 用户修改配置
|
||||
3. 点击保存时调用 `ruleStorage.update()`;返回非 `null` 才更新 `updatedAt` 并提示成功
|
||||
|
||||
---
|
||||
|
||||
### 5. 规则删除
|
||||
|
||||
**删除流程**:
|
||||
|
||||
1. 点击删除按钮
|
||||
2. 弹出确认对话框
|
||||
3. 确认后删除规则
|
||||
4. 刷新规则列表
|
||||
|
||||
**确认对话框**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 确认删除 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 确定要删除规则 "电商用户数据" 吗? │
|
||||
│ │
|
||||
│ 此操作不可撤销。 │
|
||||
│ │
|
||||
│ [取消] [删除] │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 规则加载
|
||||
|
||||
**加载流程**:
|
||||
|
||||
1. 点击"加载"按钮
|
||||
2. 将规则配置应用到当前编辑器
|
||||
3. 更新预览数据
|
||||
4. 记录使用时间和次数
|
||||
|
||||
**实现方式**:
|
||||
|
||||
```typescript
|
||||
function loadRule(ruleId: string): void {
|
||||
const rule = ruleStorage.getById(ruleId);
|
||||
if (!rule) return;
|
||||
|
||||
setFields(rule.fields);
|
||||
ruleStorage.recordUse(ruleId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 规则复制
|
||||
|
||||
**复制逻辑**:
|
||||
|
||||
1. 点击"复制"按钮
|
||||
2. 创建规则的副本
|
||||
3. 名称添加「(副本)」后缀(默认 `duplicate(id, '(副本)')`)
|
||||
4. 生成新的 ID
|
||||
5. 保存为新规则
|
||||
|
||||
---
|
||||
|
||||
### 8. 规则导出
|
||||
|
||||
**导出界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 导出规则 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 选择要导出的规则: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ☑ 电商用户数据 - 测试用 (4个字段, 100条) │ │
|
||||
│ │ ☑ 电商订单数据 - 测试用 (6个字段, 500条) │ │
|
||||
│ │ ☐ 用户登录数据 - 测试用 (3个字段, 50条) │ │
|
||||
│ │ │ │
|
||||
│ │ [全选] [全不选] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 导出格式: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ○ 导出为单个 JSON 文件 (所有规则合并) │ │
|
||||
│ │ ● 导出为多个 JSON 文件 (每个规则一个文件) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [导出] [取消] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**导出格式**(`exportRules()` 返回 `DataRule[]` 的 JSON 字符串,无外层包装):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "rule_123456",
|
||||
"name": "电商用户数据 - 测试用",
|
||||
"description": "用于测试用户注册功能",
|
||||
"fields": [],
|
||||
"createdAt": 1704067200000,
|
||||
"updatedAt": 1704067200000,
|
||||
"useCount": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. 规则导入
|
||||
|
||||
**导入界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 导入规则 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 选择导入方式: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ○ 从文件导入 │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 拖拽文件到这里,或 [点击选择文件] │ │ │
|
||||
│ │ │ 支持格式: .json │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ○ 从剪贴板粘贴 │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ 粘贴 JSON 规则内容... │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 预览导入内容: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 格式验证通过 │ │
|
||||
│ │ 规则数量: 2 个 │ │
|
||||
│ │ • 电商用户数据 (4个字段) │ │
|
||||
│ │ • 电商订单数据 (6个字段) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [导入] [取消] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**导入逻辑**:
|
||||
|
||||
1. 解析 JSON 文件
|
||||
2. 验证格式是否正确
|
||||
3. 预览导入内容
|
||||
4. 确认后逐条导入
|
||||
5. 生成新 ID 避免冲突
|
||||
6. 处理导入结果(成功/失败数量)
|
||||
|
||||
---
|
||||
|
||||
## 存储机制
|
||||
|
||||
### 本地存储
|
||||
|
||||
使用 `localStorage`,键名 `testDataGenerator_rules`。API 为**命名导出函数**(见 `src/utils/ruleStorage.ts`):
|
||||
|
||||
| 函数 | 说明 |
|
||||
| ---- | ---- |
|
||||
| `getAll()` / `getById()` / `getByName()` | 读取 |
|
||||
| `save()` / `update()` / `deleteRule()` / `duplicate()` | 写入;失败时返回 `null` 或 `false` |
|
||||
| `recordUse()` | 递增 `useCount`、更新 `lastUsedAt` |
|
||||
| `search()` / `getRecent()` | 搜索与最近使用 |
|
||||
| `exportRules()` / `importRules()` | 导入导出 JSON 数组 |
|
||||
| `clear()` | 清空全部规则 |
|
||||
|
||||
写入失败(如 `QuotaExceededError`)时,内部 `setAll()` 返回 `false`,`save`/`update` 返回 `null`,`deleteRule` 返回 `false`,并在控制台输出 `[ruleStorage] 保存规则失败`。调用方须检查返回值,避免误报成功。
|
||||
|
||||
```typescript
|
||||
import * as ruleStorage from '@/utils/ruleStorage';
|
||||
|
||||
const saved = ruleStorage.save({ name: '示例', fields });
|
||||
if (!saved) {
|
||||
// 达上限或 localStorage 不可用
|
||||
}
|
||||
```
|
||||
|
||||
### 存储限制
|
||||
|
||||
- **规则数量限制**: 最多 20 条
|
||||
- localStorage 容量: 5-10MB(浏览器限制)
|
||||
- 单个规则约 1-5KB
|
||||
- 预计可存储 1000-5000 个规则(但受数量限制,最多 20 条)
|
||||
|
||||
---
|
||||
|
||||
## 功能亮点
|
||||
|
||||
| 亮点 | 说明 |
|
||||
| -------- | ------------------------------ |
|
||||
| 本地存储 | 数据保存在浏览器本地,无需后端 |
|
||||
| 快速复用 | 一键加载已保存的规则 |
|
||||
| 导入导出 | 支持 JSON 文件导入导出 |
|
||||
| 使用统计 | 记录使用次数和时间 |
|
||||
| 搜索筛选 | 支持关键词搜索 |
|
||||
@@ -0,0 +1,489 @@
|
||||
# 界面设计
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **简洁直观** - 一目了然的布局
|
||||
2. **操作便捷** - 减少点击次数
|
||||
3. **实时反馈** - 配置即预览
|
||||
4. **响应式** - 支持多种屏幕尺寸
|
||||
|
||||
---
|
||||
|
||||
## 页面结构
|
||||
|
||||
### 主页面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🧪 测试数据生成器 [帮助] │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ 字段配置 │ │ 数据预览 │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ [+ 添加字段] │ │ [实时预览数据] │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ 字段列表... │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────────────────────────────┘ └───────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 生成选项 │ │
|
||||
│ │ 数量、格式、约束... │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 操作栏 │ │
|
||||
│ │ [生成] [保存规则] [规则管理] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 组件设计
|
||||
|
||||
### 1. 字段列表组件
|
||||
|
||||
**功能**: 展示和管理所有字段
|
||||
|
||||
**界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 字段配置 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [+ 添加字段] │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. name [中文姓名 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ 2. email [邮箱地址 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ 3. phone [手机号 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ │ 4. age [年龄 ▼] [配置] [删除] [上移][下移] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [+ 添加字段] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**交互**:
|
||||
|
||||
- 点击字段名可编辑
|
||||
- 点击生成器下拉框可切换生成器
|
||||
- 点击 配置 展开参数配置
|
||||
- 点击 删除 删除字段
|
||||
- 点击 上移下移 调整顺序
|
||||
|
||||
---
|
||||
|
||||
### 2. 字段配置组件
|
||||
|
||||
**功能**: 配置单个字段的详细参数
|
||||
|
||||
**界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 字段配置 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 基础配置 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 字段名称: [name ] │ │
|
||||
│ │ 字段描述: [用户姓名 ] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 生成器选择 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 分类: [个人信息 ▼] │ │
|
||||
│ │ 生成器: [中文姓名 ▼] │ │
|
||||
│ │ 说明: 生成中文姓名,如张三、李四 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 参数配置 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 姓氏池: [百家姓前100 ▼] │ │
|
||||
│ │ 名字长度: [1] ~ [2] │ │
|
||||
│ │ ☑ 唯一性约束 │ │
|
||||
│ │ ☑ 必填 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 空值率设置 (仅选填字段显示) │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 空值概率: [====|----] 50% │ │
|
||||
│ │ 预设: [低 20%] [中 50%] [高 80%] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 预览示例 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 张伟, 李娜, 王强, 赵敏, 刘洋 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [保存] [取消] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**交互说明**:
|
||||
|
||||
- **必填复选框**: 勾选后该字段必填,100% 生成数据
|
||||
- **空值率设置**: 仅当选填时显示,控制该字段生成空值的概率
|
||||
- **预设按钮**: 快速设置常用空值率(低/中/高)
|
||||
- **空值率滑块**: 精细调节空值概率(0-100%)
|
||||
|
||||
---
|
||||
|
||||
### 3. 数据预览组件
|
||||
|
||||
**功能**: 实时预览生成的数据
|
||||
|
||||
**界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据预览 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 预览: 前 10 条 │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ { │ │
|
||||
│ │ "name": "张伟", │ │
|
||||
│ │ "email": "zhangwei@qq.com", │ │
|
||||
│ │ "phone": "13812345678", │ │
|
||||
│ │ "age": 28 │ │
|
||||
│ │ }, │ │
|
||||
│ │ { │ │
|
||||
│ │ "name": "李娜", │ │
|
||||
│ │ "email": "lina@163.com", │ │
|
||||
│ │ "phone": "13987654321", │ │
|
||||
│ │ "age": 35 │ │
|
||||
│ │ }, │ │
|
||||
│ │ ... │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 格式切换: │
|
||||
│ [JSON] [CSV] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**功能**:
|
||||
|
||||
- 实时更新预览
|
||||
- 支持格式切换
|
||||
- 分页显示更多数据
|
||||
- 一键复制
|
||||
|
||||
---
|
||||
|
||||
### 4. 生成选项组件
|
||||
|
||||
**功能**: 配置生成参数
|
||||
|
||||
**界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 生成选项 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ 生成数量 │ │ 数据格式 │ │ 约束条件 │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ [100 ] │ │ ○ JSON │ │ ☑ name 唯一 │ │
|
||||
│ │ [10 ▼] │ │ ○ CSV │ │ ☑ email 唯一 │ │
|
||||
│ │ [100 ▼] │ │ │ │ ☑ phone 唯一 │ │
|
||||
│ │ [自定义 ] │ │ │ │ ☐ age 唯一 │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ └─────────────────┘ └─────────────────┘ └───────────────────────┘ │
|
||||
│ │
|
||||
│ 默认空值率 (选填字段) │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 空值概率: [====|----] 50% │ │
|
||||
│ │ 预设: [低 20%] [中 50%] [高 80%] │ │
|
||||
│ │ 说明: 未单独设置空值率的选填字段将使用此默认值 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 操作栏组件
|
||||
|
||||
**功能**: 顶部操作按钮
|
||||
|
||||
**界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 操作栏 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [生成数据] [保存规则] [规则管理] [导入规则] [导出]│
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 生成结果组件
|
||||
|
||||
**功能**: 展示生成结果,包含成功、警告、错误三种状态
|
||||
|
||||
**成功状态界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 生成结果 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 生成完成 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [成功图标] 成功生成 100 条数据,耗时 1234ms │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 数据统计: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • 总条数: 100 │ │
|
||||
│ │ • 成功条数: 100 │ │
|
||||
│ │ • 失败条数: 0 │ │
|
||||
│ │ • 文件大小: 12.5 KB │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 数据预览: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [JSON 格式预览...] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 导出选项: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [复制全部] [下载 JSON] [下载 CSV] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [关闭] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**警告状态界面(部分字段失败)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 生成结果 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 部分字段生成失败 │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [警告图标] 有 2 个字段生成失败 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 失败详情: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [警告图标] "phone" - 重试 100 次后仍存在重复 │ │
|
||||
│ │ [警告图标] "email" - 重试 100 次后仍存在重复 │ │
|
||||
│ │ │ │
|
||||
│ │ 提示: 这些字段的值已设置为空,您可以尝试减少生成数量 │ │
|
||||
│ │ 或调整字段配置 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 数据统计: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • 总条数: 100 │ │
|
||||
│ │ • 成功条数: 98 │ │
|
||||
│ │ • 失败条数: 2 │ │
|
||||
│ │ • 文件大小: 12.3 KB │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 数据预览: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [JSON 格式预览...] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 导出选项: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [复制全部] [下载 JSON] [下载 CSV] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [关闭] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**错误状态界面(生成失败)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 生成失败 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ [错误图标] 生成过程中发生错误 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 错误详情: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ 错误类型: 生成器不存在 │ │
|
||||
│ │ 错误信息: 字段 "phone" 的生成器 "chinesePhone1" 不存在 │ │
|
||||
│ │ 错误位置: 字段配置第 2 行 │ │
|
||||
│ │ │ │
|
||||
│ │ 建议操作: │ │
|
||||
│ │ 1. 检查字段配置中的生成器名称是否正确 │ │
|
||||
│ │ 2. 确认该生成器已正确注册 │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [重新配置] [关闭] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.1 Toast 提示组件
|
||||
|
||||
**功能**: 显示临时提示信息
|
||||
|
||||
**成功提示**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ [成功图标] 数据生成成功 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**警告提示**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [警告图标] 有 2 个字段生成失败:phone、email │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**错误提示**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [错误图标] 生成失败:生成器 "xxx" 不存在 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Modal 提示组件
|
||||
|
||||
**功能**: 显示严重错误的模态框
|
||||
|
||||
**错误模态框**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ [错误图标] 生成失败 │ │
|
||||
│ │ │ │
|
||||
│ │ 字段 "phone" 的生成器 "chinesePhone1" 不存在 │ │
|
||||
│ │ │ │
|
||||
│ │ 请检查字段配置中的生成器名称是否正确 │ │
|
||||
│ │ │ │
|
||||
│ │ ─────────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ [ 确定 ] │ │
|
||||
│ │ │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 规则管理组件
|
||||
|
||||
**功能**: 管理已保存的规则
|
||||
|
||||
**界面**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 规则管理 │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 搜索: [搜索规则名称... ] │
|
||||
│ │
|
||||
│ 规则列表: │
|
||||
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 电商用户数据 - 测试用 │ │
|
||||
│ │ 用于测试用户注册功能 │ │
|
||||
│ │ 4 个字段 | 100 条 | JSON │ │
|
||||
│ │ 2024-01-15 创建 | 使用 12 次 │ │
|
||||
│ │ [加载] [编辑] [导出] [删除] │ │
|
||||
│ │ │ │
|
||||
│ │ 电商订单数据 - 测试用 │ │
|
||||
│ │ 用于测试订单功能 │ │
|
||||
│ │ 6 个字段 | 500 条 | JSON │ │
|
||||
│ │ 2024-01-16 创建 | 使用 8 次 │ │
|
||||
│ │ [加载] [编辑] [导出] [删除] │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [导入规则] [导出全部] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 响应式设计
|
||||
|
||||
### 桌面端 (>1024px)
|
||||
|
||||
- 左右分栏布局
|
||||
- 字段配置区占 60%
|
||||
- 数据预览区占 40%
|
||||
|
||||
---
|
||||
|
||||
## 交互细节
|
||||
|
||||
### 拖拽排序
|
||||
|
||||
- 字段支持拖拽排序(基于 @dnd-kit/sortable)
|
||||
- 拖拽手柄(GripVertical 图标)位于字段左侧
|
||||
- 拖拽时显示半透明效果和阴影
|
||||
- 释放后立即生效,自动更新字段顺序
|
||||
|
||||
### 实时预览
|
||||
|
||||
- 配置参数时实时更新预览
|
||||
- 延迟 300ms 防抖,避免频繁重渲染
|
||||
- 预览最多显示 10 条示例数据
|
||||
- 生成完成后切换为完整结果预览
|
||||
|
||||
### 虚拟列表
|
||||
|
||||
- 数据量超过 100 条时自动启用虚拟滚动
|
||||
- 仅渲染可见区域的行,大幅减少 DOM 节点
|
||||
- 行高固定 20px,支持快速滚动
|
||||
|
||||
---
|
||||
|
||||
## 主题支持
|
||||
|
||||
### 浅色主题
|
||||
|
||||
- 背景色: #ffffff
|
||||
- 文字色: #333333
|
||||
- 边框色: #e0e0e0
|
||||
- 主题色: #3b82f6
|
||||
|
||||
### 深色主题
|
||||
|
||||
- 背景色: #1a1a1a
|
||||
- 文字色: #ffffff
|
||||
- 边框色: #404040
|
||||
- 主题色: #60a5fa
|
||||
@@ -1,33 +0,0 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
import { browser } from 'wxt/browser';
|
||||
import { MessageAction, onMessage } from '@/utils/messages';
|
||||
|
||||
export default defineBackground(() => {
|
||||
// 监听扩展图标点击事件,打开侧边栏
|
||||
browser.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id) {
|
||||
try {
|
||||
await browser.sidePanel.open({ tabId: tab.id });
|
||||
} catch (err) {
|
||||
console.error('Failed to open side panel:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 使用 @webext-core/messaging 处理消息
|
||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||
const { tabId, delay = 0 } = message.data;
|
||||
|
||||
const executeReload = () => {
|
||||
browser.tabs.reload(tabId);
|
||||
};
|
||||
|
||||
if (delay > 0) {
|
||||
setTimeout(executeReload, delay);
|
||||
} else {
|
||||
executeReload();
|
||||
}
|
||||
|
||||
return { success: true, message: '刷新请求已接收' };
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
import { initFormMappingHelper } from '@/utils/formMapping/ui';
|
||||
import { initMessageHandler } from './content/messageHandler';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_end',
|
||||
main() {
|
||||
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
|
||||
initFormMappingHelper();
|
||||
|
||||
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
|
||||
initMessageHandler();
|
||||
},
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import {
|
||||
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';
|
||||
|
||||
// 存储当前扫描到的字段列表,用于高亮联动
|
||||
let currentFields: FormFieldInfo[] = [];
|
||||
|
||||
/**
|
||||
* 初始化消息处理器
|
||||
*/
|
||||
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 : '注入失败',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Switch,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Stack,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import {
|
||||
getFeatureByKey,
|
||||
getDefaultPageOrder,
|
||||
getDefaultVisibleFeatureKeys,
|
||||
} from '@/config/features';
|
||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
|
||||
export default function App() {
|
||||
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
||||
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { snackbarProps, showMessage } = useSnackbarState();
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const [savedVisible, savedOrder] = await Promise.all([
|
||||
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
|
||||
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
|
||||
]);
|
||||
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
|
||||
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
setVisiblePages(getDefaultVisibleFeatureKeys());
|
||||
setPageOrder(getDefaultPageOrder());
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageToggle = async (page: PageType) => {
|
||||
const isCurrentlyVisible = visiblePages.includes(page);
|
||||
let newPages: PageType[];
|
||||
|
||||
if (isCurrentlyVisible) {
|
||||
if (visiblePages.length <= 1) {
|
||||
showToast('至少需要保留一个可见页面', 'warning');
|
||||
return;
|
||||
}
|
||||
newPages = visiblePages.filter((p) => p !== page);
|
||||
} else {
|
||||
newPages = [...visiblePages, page];
|
||||
}
|
||||
|
||||
try {
|
||||
await storageUtil.set('app/visiblePages', newPages);
|
||||
setVisiblePages(newPages);
|
||||
const feature = getFeatureByKey(page);
|
||||
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
showToast('保存失败', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = async (index: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && index === 0) return;
|
||||
if (direction === 'down' && index === pageOrder.length - 1) return;
|
||||
|
||||
const newOrder = [...pageOrder];
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
|
||||
|
||||
try {
|
||||
await storageUtil.set('app/pageOrder', newOrder);
|
||||
setPageOrder(newOrder);
|
||||
} catch (error) {
|
||||
console.error('Failed to save order:', error);
|
||||
showToast('排序保存失败', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefaults = async () => {
|
||||
try {
|
||||
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
|
||||
const defaults = getDefaultVisibleFeatureKeys();
|
||||
const defaultOrder = getDefaultPageOrder();
|
||||
|
||||
await Promise.all([
|
||||
storageUtil.set('app/visiblePages', defaults),
|
||||
storageUtil.set('app/pageOrder', defaultOrder),
|
||||
]);
|
||||
|
||||
setVisiblePages(defaults);
|
||||
setPageOrder(defaultOrder);
|
||||
showToast('已恢复默认', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to restore defaults:', error);
|
||||
showToast('恢复失败', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
||||
showMessage(message, { severity });
|
||||
};
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="app"
|
||||
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="flex-start"
|
||||
sx={{ mb: 4 }}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={handleRestoreDefaults}
|
||||
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>
|
||||
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{feature.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleMove(index, 'up')}
|
||||
disabled={index === 0}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
<KeyboardArrowUpIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleMove(index, 'down')}
|
||||
disabled={index === array.length - 1}
|
||||
sx={{ color: 'text.secondary' }}
|
||||
>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isChecked}
|
||||
onChange={() => handlePageToggle(key)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>扩展设置 - Testing Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import theme from '@/config/theme';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,43 +0,0 @@
|
||||
import RouterProvider from '@/providers/RouterProvider';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { globalStyles } from '@/config/pageTheme';
|
||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function App() {
|
||||
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
|
||||
const handleOpenOptions = () => {
|
||||
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterProvider syncKey="app/popupRoute">
|
||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
|
||||
<Box
|
||||
className="app"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
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} />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</SnackbarProvider>
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>我是独立窗口</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import theme from '@/config/theme';
|
||||
import App from './App.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<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,
|
||||
};
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
||||
import type {
|
||||
StorageCleanerOptions,
|
||||
CleaningResult,
|
||||
StorageCleanerPreferences,
|
||||
} from '@/types/storage';
|
||||
import {
|
||||
getCurrentTab,
|
||||
isRestrictedUrl,
|
||||
clearStorage,
|
||||
getCookieSize,
|
||||
getLocalStorageSize,
|
||||
getSessionStorageSize,
|
||||
getIndexedDBSize,
|
||||
getCacheStorageSize,
|
||||
getServiceWorkerCount,
|
||||
} from '@/utils/storageCleaner';
|
||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||
|
||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
};
|
||||
|
||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||
autoRefresh: true,
|
||||
selectedTypes: DEFAULT_OPTIONS,
|
||||
};
|
||||
|
||||
export interface UseStorageCleanerReturn {
|
||||
// State
|
||||
domain: string;
|
||||
error: string;
|
||||
isInitializing: boolean;
|
||||
options: StorageCleanerOptions;
|
||||
sizes: Record<string, number>;
|
||||
autoRefresh: boolean;
|
||||
loading: boolean;
|
||||
result: CleaningResult | null;
|
||||
showConfirm: boolean;
|
||||
setShowConfirm: (show: boolean) => void;
|
||||
|
||||
// Computed
|
||||
totalSize: number;
|
||||
allSelected: boolean;
|
||||
someSelected: boolean;
|
||||
|
||||
// Handlers
|
||||
handleAutoRefreshChange: (checked: boolean) => Promise<void>;
|
||||
handleOptionChange: (key: keyof StorageCleanerOptions) => Promise<void>;
|
||||
handleSelectAll: (checked: boolean) => Promise<void>;
|
||||
handleClean: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UseStorageCleanerOptions {
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
}
|
||||
|
||||
export function useStorageCleaner({
|
||||
showMessage,
|
||||
}: UseStorageCleanerOptions): UseStorageCleanerReturn {
|
||||
const [domain, setDomain] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [isInitializing, setIsInitializing] = useState<boolean>(true);
|
||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||
const [sizes, setSizes] = useState<Record<string, number>>({});
|
||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||
const resultTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const requestIdRef = useRef<number>(0);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resultTimeout = resultTimeoutRef.current;
|
||||
return () => {
|
||||
if (resultTimeout) clearTimeout(resultTimeout);
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadInfo = useCallback(async () => {
|
||||
const currentRequestId = ++requestIdRef.current;
|
||||
try {
|
||||
const tab = await getCurrentTab();
|
||||
if (currentRequestId !== requestIdRef.current) return;
|
||||
|
||||
if (!tab || !tab.url) {
|
||||
setError('无法获取当前标签页');
|
||||
return;
|
||||
}
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
setError('存储清理功能不支持此页面');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
const url = tab.url;
|
||||
const tabId = tab.id!;
|
||||
setDomain(new URL(url).hostname);
|
||||
|
||||
const [savedPrefs, cSize, lsSize, ssSize, idbSize, cacheCount, swCount] = await Promise.all([
|
||||
storageUtil.get('storageCleaner/preferences', DEFAULT_PREFERENCES),
|
||||
getCookieSize(url),
|
||||
getLocalStorageSize(tabId),
|
||||
getSessionStorageSize(tabId),
|
||||
getIndexedDBSize(tabId),
|
||||
getCacheStorageSize(tabId),
|
||||
getServiceWorkerCount(tabId),
|
||||
]);
|
||||
|
||||
if (currentRequestId !== requestIdRef.current) return;
|
||||
|
||||
if (savedPrefs) {
|
||||
setAutoRefresh(savedPrefs.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
||||
setOptions(savedPrefs.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
||||
}
|
||||
|
||||
setSizes({
|
||||
cookies: cSize,
|
||||
localStorage: lsSize,
|
||||
sessionStorage: ssSize,
|
||||
indexedDB: idbSize,
|
||||
cacheStorage: cacheCount,
|
||||
serviceWorkers: swCount,
|
||||
});
|
||||
} finally {
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadInfoRef = useRef(loadInfo);
|
||||
loadInfoRef.current = loadInfo;
|
||||
|
||||
const debouncedLoadInfo = useCallback(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
loadInfoRef.current().then((r) => console.info(r));
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 首次加载不防抖
|
||||
loadInfoRef.current().then((r) => console.info(r));
|
||||
|
||||
const handleTabChange = () => debouncedLoadInfo();
|
||||
const handleTabUpdated = (_tabId: number, changeInfo: { status?: string; url?: string }) => {
|
||||
if (changeInfo.status === 'complete' || changeInfo.url) {
|
||||
debouncedLoadInfo();
|
||||
}
|
||||
};
|
||||
|
||||
chrome.tabs.onActivated.addListener(handleTabChange);
|
||||
chrome.tabs.onUpdated.addListener(handleTabUpdated);
|
||||
chrome.windows.onFocusChanged.addListener(handleTabChange);
|
||||
|
||||
return () => {
|
||||
chrome.tabs.onActivated.removeListener(handleTabChange);
|
||||
chrome.tabs.onUpdated.removeListener(handleTabUpdated);
|
||||
chrome.windows.onFocusChanged.removeListener(handleTabChange);
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [debouncedLoadInfo]);
|
||||
|
||||
const handleAutoRefreshChange = useCallback(
|
||||
async (checked: boolean) => {
|
||||
setAutoRefresh(checked);
|
||||
await storageUtil.set('storageCleaner/preferences', {
|
||||
autoRefresh: checked,
|
||||
selectedTypes: options,
|
||||
});
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const handleOptionChange = useCallback(
|
||||
async (key: keyof StorageCleanerOptions) => {
|
||||
setOptions((prev) => {
|
||||
const newOptions = { ...prev, [key]: !prev[key] };
|
||||
storageUtil.set('storageCleaner/preferences', {
|
||||
autoRefresh,
|
||||
selectedTypes: newOptions,
|
||||
});
|
||||
return newOptions;
|
||||
});
|
||||
},
|
||||
[autoRefresh],
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(
|
||||
async (checked: boolean) => {
|
||||
const newOptions = {
|
||||
localStorage: checked,
|
||||
sessionStorage: checked,
|
||||
indexedDB: checked,
|
||||
cookies: checked,
|
||||
cacheStorage: checked,
|
||||
serviceWorkers: checked,
|
||||
};
|
||||
setOptions(newOptions);
|
||||
await storageUtil.set('storageCleaner/preferences', {
|
||||
autoRefresh,
|
||||
selectedTypes: newOptions,
|
||||
});
|
||||
},
|
||||
[autoRefresh],
|
||||
);
|
||||
|
||||
const handleClean = useCallback(async () => {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab || !tab.id || !tab.url) {
|
||||
showMessage('无法获取当前标签页', { severity: 'warning' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||
setResult(cleaningResult);
|
||||
|
||||
if (autoRefresh && cleaningResult.success) {
|
||||
showMessage('清理成功,即将刷新页面', { severity: 'success' });
|
||||
await sendMessage(MessageAction.RELOAD_TAB, { tabId: tab.id, delay: 1000 });
|
||||
} else {
|
||||
await loadInfo();
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage(`清理失败: ${String(err)}`, { severity: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowConfirm(false);
|
||||
}
|
||||
}, [options, autoRefresh, showMessage, loadInfo]);
|
||||
|
||||
// Computed values
|
||||
// Note: sizes.indexedDB contains navigator.storage.estimate().usage
|
||||
// which includes IndexedDB, Cache, etc.
|
||||
const totalSize = (sizes.cookies || 0) + (sizes.indexedDB || 0);
|
||||
|
||||
const allSelected = Object.values(options).every(Boolean);
|
||||
const someSelected = Object.values(options).some(Boolean) && !allSelected;
|
||||
|
||||
return {
|
||||
domain,
|
||||
error,
|
||||
isInitializing,
|
||||
options,
|
||||
sizes,
|
||||
autoRefresh,
|
||||
loading,
|
||||
result,
|
||||
showConfirm,
|
||||
setShowConfirm,
|
||||
totalSize,
|
||||
allSelected,
|
||||
someSelected,
|
||||
handleAutoRefreshChange,
|
||||
handleOptionChange,
|
||||
handleSelectAll,
|
||||
handleClean,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import RouterProvider from '@/providers/RouterProvider';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default function App() {
|
||||
const handleOpenOptions = () => {
|
||||
chrome.runtime.openOptionsPage();
|
||||
};
|
||||
|
||||
// 通知侧边栏已打开
|
||||
useEffect(() => {
|
||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||
return () => {
|
||||
// 尝试在关闭时通知,虽然在某些情况下可能无法成功发送
|
||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||
<Box
|
||||
className="app"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<TopBar onOpenOptions={handleOpenOptions} />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Testing Tools - Side Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import theme from '@/config/theme';
|
||||
import App from './App.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -4,52 +4,67 @@ import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'dist',
|
||||
'.wxt',
|
||||
'node_modules',
|
||||
'eslint.config.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.test.ts',
|
||||
'**/__tests__/**',
|
||||
],
|
||||
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: [
|
||||
'hooks/**/*.{ts,tsx}',
|
||||
'entrypoints/**/*.{ts,tsx}',
|
||||
'pages/**/*.{ts,tsx}',
|
||||
'utils/**/*.{ts,tsx}',
|
||||
'components/**/*.{ts,tsx}',
|
||||
'services/**/*.{ts,tsx}',
|
||||
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: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
react: reactPlugin as any,
|
||||
'react-hooks': reactHooks as any,
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs['jsx-runtime'].rules,
|
||||
|
||||
'react/prop-types': 'off',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
);
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
/* global process */
|
||||
|
||||
const isCI = process.env.CI === 'true';
|
||||
|
||||
export default {
|
||||
// 对于代码文件:
|
||||
'*.{ts,tsx,js,jsx,mjs}': [
|
||||
// 1. Prettier: 全局格式化
|
||||
'eslint --fix --no-warn-ignored',
|
||||
...(isCI ? ['eslint --max-warnings=0'] : []),
|
||||
'prettier --write',
|
||||
|
||||
// 2. ESLint: 检查并自动修复
|
||||
// --no-warn-ignored: 抑制对忽略文件的警告(eslint.config.ts 中忽略了测试文件)
|
||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
||||
|
||||
// 3. TypeScript: 类型检查
|
||||
() => 'tsc --noEmit',
|
||||
],
|
||||
|
||||
// 对于其他文件:
|
||||
'*.{json,css,scss,md}': ['prettier --write'],
|
||||
};
|
||||
|
||||
@@ -12,51 +12,65 @@
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare",
|
||||
"prepare": "husky",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"@webext-core/messaging": "^3.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrious": "^4.0.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/chrome": "^0.1.36",
|
||||
"@types/react": "^19.2.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chrome": "^0.1.42",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/webextension-polyfill": "^0.12.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"@types/webextension-polyfill": "^0.12.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^25.0.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.8.1",
|
||||
"terser": "^5.46.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"terser": "^5.47.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vitest": "^2.0.0",
|
||||
"wxt": "^0.20.6"
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vitest": "^4.1.7",
|
||||
"wxt": "^0.20.26"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
};
|
||||
@@ -1,267 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import type { PageType, StorageSchema } from '@/types/storage';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import {
|
||||
getDefaultVisibleFeatureKeys,
|
||||
getDefaultPageOrder,
|
||||
getAllFeatureKeys,
|
||||
} from '@/config/features';
|
||||
|
||||
/**
|
||||
* 校验是否为合法的页面类型
|
||||
*/
|
||||
const isValidPage = (page: unknown): page is PageType => {
|
||||
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验页面列表是否合法(所有项都必须是合法的 PageType)
|
||||
*/
|
||||
const isValidPageList = (pages: unknown): pages is PageType[] => {
|
||||
return Array.isArray(pages) && pages.every(isValidPage);
|
||||
};
|
||||
|
||||
/**
|
||||
* 路由上下文类型定义
|
||||
*/
|
||||
interface RouterContextType {
|
||||
/** 当前所在页面 */
|
||||
currentPage: PageType;
|
||||
/** 当前可见的页面列表(用于侧边栏/菜单显示) */
|
||||
visiblePages: PageType[];
|
||||
/** 页面显示顺序 */
|
||||
pageOrder: PageType[];
|
||||
/** 路由数据是否已从存储中加载完成 */
|
||||
isLoaded: boolean;
|
||||
/** 导航到指定页面 */
|
||||
navigateTo: (page: PageType) => void;
|
||||
/** 仅在本地(当前组件状态)跳转,不影响其他同步端 */
|
||||
navigateLocal: (page: PageType) => void;
|
||||
/** 强制同步当前路由到存储 */
|
||||
syncNavigation: (page: PageType) => void;
|
||||
/** 返回仪表盘 */
|
||||
goBack: () => void;
|
||||
/** 设置可见页面列表 */
|
||||
setVisiblePages: (pages: PageType[]) => void;
|
||||
/** 设置页面显示顺序 */
|
||||
setPageOrder: (pages: PageType[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建路由上下文
|
||||
*/
|
||||
const RouterContext = createContext<RouterContextType | null>(null);
|
||||
|
||||
/**
|
||||
* 路由提供者组件参数类型
|
||||
*/
|
||||
interface RouterProviderProps {
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
/** 默认初始路由,默认为 'dashboard' */
|
||||
defaultRoute?: PageType;
|
||||
/** 是否同步路由状态到存储,默认为 true */
|
||||
syncRoute?: boolean;
|
||||
/** 存储路由状态的键名,默认为 'app/currentRoute' */
|
||||
syncKey?: keyof StorageSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步从 localStorage 获取存储快照(用于消除异步加载产生的首屏闪烁)
|
||||
*/
|
||||
const getSyncSnapshot = <T,>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
validator?: (val: unknown) => val is T,
|
||||
): T => {
|
||||
try {
|
||||
const val = localStorage.getItem(`snapshot/${key}`);
|
||||
if (!val) return defaultValue;
|
||||
const parsed = JSON.parse(val) as unknown;
|
||||
if (validator) {
|
||||
return validator(parsed) ? parsed : defaultValue;
|
||||
}
|
||||
return (parsed as T) ?? defaultValue;
|
||||
} catch (error) {
|
||||
console.error('解析同步快照失败:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 路由提供者组件
|
||||
* 负责管理应用内部的路由状态、页面可见性及排序,并支持与 Chrome Storage 同步
|
||||
*/
|
||||
export function RouterProvider({
|
||||
children,
|
||||
defaultRoute = 'dashboard',
|
||||
syncRoute = true,
|
||||
syncKey = 'app/currentRoute',
|
||||
}: RouterProviderProps) {
|
||||
// 当前页面状态:优先从同步快照加载,并进行合法性校验
|
||||
const [currentPage, setCurrentPage] = useState<PageType>(() =>
|
||||
getSyncSnapshot(syncKey as string, defaultRoute, isValidPage),
|
||||
);
|
||||
// 可见页面列表状态
|
||||
const [visiblePages, setVisiblePages] = useState<PageType[]>(() =>
|
||||
getSyncSnapshot('app/visiblePages', getDefaultVisibleFeatureKeys(), isValidPageList),
|
||||
);
|
||||
// 页面排序状态
|
||||
const [pageOrder, setPageOrder] = useState<PageType[]>(() =>
|
||||
getSyncSnapshot('app/pageOrder', getDefaultPageOrder(), isValidPageList),
|
||||
);
|
||||
// 加载完成标识
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
/**
|
||||
* 从存储中加载初始路由数据
|
||||
*/
|
||||
const loadInitialData = useCallback(async () => {
|
||||
try {
|
||||
const savedRoute = await storageUtil.get(syncKey, defaultRoute);
|
||||
const savedVisiblePages = await storageUtil.get(
|
||||
'app/visiblePages',
|
||||
getDefaultVisibleFeatureKeys(),
|
||||
);
|
||||
const savedPageOrder = await storageUtil.get('app/pageOrder', getDefaultPageOrder());
|
||||
|
||||
// 增加数据合法性校验并进行类型收窄
|
||||
if (isValidPage(savedRoute) && syncRoute) {
|
||||
setCurrentPage(savedRoute);
|
||||
}
|
||||
if (isValidPageList(savedVisiblePages)) {
|
||||
setVisiblePages(savedVisiblePages);
|
||||
}
|
||||
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
||||
setPageOrder(savedPageOrder);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载初始路由数据失败:', error);
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, [defaultRoute, syncKey, syncRoute]);
|
||||
|
||||
// 组件挂载时加载初始数据
|
||||
useEffect(() => {
|
||||
loadInitialData().catch(console.error);
|
||||
}, [loadInitialData]);
|
||||
|
||||
// 当 currentPage 改变时,如果开启了同步,则持久化到存储和本地快照
|
||||
useEffect(() => {
|
||||
if (isLoaded && syncRoute) {
|
||||
storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
|
||||
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
|
||||
}
|
||||
}, [currentPage, isLoaded, syncRoute, syncKey]);
|
||||
|
||||
// 持久化可见页面列表
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
storageUtil.set('app/visiblePages', visiblePages).catch(console.error);
|
||||
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(visiblePages));
|
||||
}
|
||||
}, [visiblePages, isLoaded]);
|
||||
|
||||
// 持久化页面排序
|
||||
useEffect(() => {
|
||||
if (isLoaded) {
|
||||
storageUtil.set('app/pageOrder', pageOrder).catch(console.error);
|
||||
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(pageOrder));
|
||||
}
|
||||
}, [pageOrder, isLoaded]);
|
||||
|
||||
/**
|
||||
* 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!syncRoute) return;
|
||||
|
||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||
// 同步当前路由
|
||||
if (syncRoute && changes[syncKey as string]) {
|
||||
const newRoute = changes[syncKey as string].newValue as PageType;
|
||||
if (newRoute && newRoute !== currentPage && isValidPage(newRoute)) {
|
||||
setCurrentPage(newRoute);
|
||||
}
|
||||
}
|
||||
// 同步可见页面列表
|
||||
if (changes['app/visiblePages']) {
|
||||
const newPages = changes['app/visiblePages'].newValue;
|
||||
if (isValidPageList(newPages)) {
|
||||
setVisiblePages(newPages);
|
||||
}
|
||||
}
|
||||
// 同步页面排序
|
||||
if (changes['app/pageOrder']) {
|
||||
const newOrder = changes['app/pageOrder'].newValue;
|
||||
if (isValidPageList(newOrder)) {
|
||||
setPageOrder(newOrder);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||
}, [syncRoute, currentPage, syncKey]);
|
||||
|
||||
/**
|
||||
* 跳转到指定页面
|
||||
*/
|
||||
const navigateTo = (page: PageType) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅在本地跳转,不触发自动同步(通常由 handleStorageChange 内部调用)
|
||||
*/
|
||||
const navigateLocal = (page: PageType) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
/**
|
||||
* 手动同步导航状态到存储
|
||||
*/
|
||||
const syncNavigation = (page: PageType) => {
|
||||
storageUtil.set(syncKey, page as StorageSchema[typeof syncKey]).catch(console.error);
|
||||
};
|
||||
|
||||
/**
|
||||
* 返回主仪表盘
|
||||
*/
|
||||
const goBack = () => {
|
||||
setCurrentPage('dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<RouterContext.Provider
|
||||
value={{
|
||||
currentPage,
|
||||
visiblePages,
|
||||
pageOrder,
|
||||
isLoaded,
|
||||
navigateTo,
|
||||
navigateLocal,
|
||||
syncNavigation,
|
||||
goBack,
|
||||
setVisiblePages,
|
||||
setPageOrder,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RouterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 Hook:获取路由上下文
|
||||
* @throws {Error} 如果在 RouterProvider 之外使用则抛出异常
|
||||
*/
|
||||
export function useRouter() {
|
||||
const context = useContext(RouterContext);
|
||||
if (!context) {
|
||||
throw new Error('useRouter must be used within a RouterProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default RouterProvider;
|
||||
@@ -0,0 +1,20 @@
|
||||
# public/
|
||||
|
||||
静态资源目录,存放无需构建处理的文件,会被直接复制到输出目录。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件/目录 | 用途 |
|
||||
| -------------- | -------------------------------- |
|
||||
| `icon/` | 扩展图标,提供多种尺寸 |
|
||||
| `icon/16.png` | 16×16 图标(工具栏) |
|
||||
| `icon/32.png` | 32×32 图标 |
|
||||
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
||||
| `icon/96.png` | 96×96 图标 |
|
||||
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
||||
- 图标格式推荐使用 PNG,确保透明背景
|
||||
- UI 文案不在此目录维护,见 `src/config/features.tsx` 与各页面组件
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 716 KiB |