Compare commits
334 Commits
v1.0.1
..
2967a6f79f
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 8d36f21f1b | |||
| 60537f6e2e | |||
| c039475119 | |||
| fb5f98dd3b | |||
| af0089c980 | |||
| 4850c92365 | |||
| 0379f96e80 | |||
| be5e2f02ee | |||
| bbd0507bcd | |||
| 9a947d7431 | |||
| 84ffd2a132 | |||
| 2cd21973f3 | |||
| 979b45a898 | |||
| 8bc11eb696 | |||
| 71b9dcc35c | |||
| f4c2f6d374 |
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"WebSearch",
|
|
||||||
"Bash(npm install:*)",
|
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(npm run:*)",
|
"Bash(npx vitest:*)",
|
||||||
"Bash(git show-ref:*)",
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(git reset:*)",
|
||||||
"Bash(git checkout:*)",
|
"Bash(git checkout:*)",
|
||||||
"mcp__plugin_playwright_playwright__browser_navigate"
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx eslint:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(pnpm typecheck *)",
|
||||||
|
"Bash(pnpm test *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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`
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup:
|
||||||
|
name: Prepare Dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
cache-key: ${{ steps.cache-info.outputs.key }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
|
- name: Run TypeScript type check
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build (${{ matrix.browser }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ lint, typecheck, test ]
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
browser: [ chrome, firefox ]
|
||||||
|
fail-fast: false
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
|
- name: Build Extension (${{ matrix.browser }})
|
||||||
|
run: |
|
||||||
|
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
||||||
|
npm run build:${{ matrix.browser }}
|
||||||
|
else
|
||||||
|
npm run build -- --browser ${{ matrix.browser }}
|
||||||
|
fi
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
|
||||||
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
|
||||||
|
|
||||||
name: Node.js CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ "main" ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ "main" ]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
node-version: [22.x]
|
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node-version }}
|
|
||||||
cache: 'npm'
|
|
||||||
- run: npm install
|
|
||||||
- run: npm run build --if-present
|
|
||||||
# - run: npm test
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ── Phase 1: 依赖准备 ───────────────────
|
||||||
|
setup:
|
||||||
|
name: Prepare Dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
id: cache-nodemodules
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-release-v22-
|
||||||
|
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# ── Phase 2: 质量检查 ──────────────────
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
name: TypeScript Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
|
- run: npm run typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Unit Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
|
# ── Phase 3: 打包 ───────────────────────
|
||||||
|
build-extension:
|
||||||
|
name: Package Extension
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, typecheck, test]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Sync version from tag
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "Setting version to $VERSION"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
|
||||||
|
- name: Build and Zip Extension
|
||||||
|
run: |
|
||||||
|
npm run zip
|
||||||
|
npm run zip:firefox
|
||||||
|
|
||||||
|
# 🔍 调试:确认 zip 文件位置
|
||||||
|
- name: Verify output files
|
||||||
|
run: |
|
||||||
|
echo "=== .output/ contents ==="
|
||||||
|
ls -la .output/ || echo "Not found"
|
||||||
|
echo "=== All zip files ==="
|
||||||
|
find . -name "*.zip" -type f
|
||||||
|
|
||||||
|
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
|
||||||
|
- name: Prepare release artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-archives
|
||||||
|
cp .output/*.zip release-archives/ 2>/dev/null || true
|
||||||
|
echo "Files in release-archives:"
|
||||||
|
ls -la release-archives/
|
||||||
|
|
||||||
|
- name: Upload Extension Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: release-archives/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# ── Phase 4: 发布 ───────────────────────
|
||||||
|
release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-extension
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Extension Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: release-artifacts
|
||||||
|
|
||||||
|
# 🔍 调试:确认下载成功
|
||||||
|
- name: Verify downloaded artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== release-artifacts/ ==="
|
||||||
|
ls -la release-artifacts/
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: "v${{ steps.version.outputs.version }}"
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
release-artifacts/*.zip
|
||||||
@@ -13,7 +13,11 @@ stats.html
|
|||||||
stats-*.json
|
stats-*.json
|
||||||
.wxt
|
.wxt
|
||||||
.vitest
|
.vitest
|
||||||
.claude
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -25,3 +29,22 @@ stats-*.json
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
.trae/*
|
||||||
|
.workbuddy/*
|
||||||
|
.qoder/*
|
||||||
|
dev/*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Other
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.tsxbuildinfo
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
npx lint-staged
|
# 后台运行类型检查,结果输出到 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,143 +1,192 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
|
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
|
||||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
|
||||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
|
||||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
|
||||||
- `npm run zip` - 打包 Chrome 扩展
|
|
||||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
|
||||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
|
||||||
|
|
||||||
### 测试相关
|
|
||||||
|
|
||||||
- `npm run test` - 运行所有测试(单次执行)
|
|
||||||
- `npm run test:watch` - 运行测试并监听文件变化
|
|
||||||
- `npm run test:coverage` - 运行测试并生成覆盖率报告
|
|
||||||
|
|
||||||
**运行单个测试文件:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx vitest run components/__tests__/CopyButton.test.tsx
|
npm run dev # Chrome 开发模式 (HMR)
|
||||||
|
npm run dev:firefox # Firefox 开发模式
|
||||||
|
npm run build # Chrome 生产构建
|
||||||
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
|
npm run lint # ESLint (--max-warnings=0)
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run test # vitest run (单次执行)
|
||||||
|
npm run test:watch # vitest 监视模式
|
||||||
|
npm run test:coverage # 带覆盖率的测试
|
||||||
|
npx vitest run path/to/file.test.ts # 运行单个测试文件
|
||||||
|
npx wxt prepare # 重新生成 .wxt/ 类型声明(npm install 时 postinstall 会自动执行)
|
||||||
```
|
```
|
||||||
|
|
||||||
**测试技术栈:**
|
修改 `package.json` 或首次克隆仓库后需执行 `npm install`,会自动触发 `postinstall` → `wxt prepare`。
|
||||||
|
|
||||||
- Vitest - 测试框架
|
## 验证流程
|
||||||
- @testing-library/react - React 组件测试
|
|
||||||
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent)
|
|
||||||
- jsdom - 浏览器环境模拟
|
|
||||||
|
|
||||||
### 依赖与准备
|
详见 [CI 配置](./.github/CI.md)。本地与 CI 的检查层次如下。
|
||||||
|
|
||||||
- `npm install` - 安装依赖
|
### Pre-commit(`.husky/pre-commit`)
|
||||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
|
||||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
|
||||||
|
|
||||||
## 项目架构
|
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 (Web Extension Toolkit) - 浏览器扩展开发框架
|
1. 全项目 `tsc --noEmit`(阻塞推送)
|
||||||
- **前端**: React 19 + TypeScript
|
2. 对本次推送相对 upstream(或 `origin/main`)变更的 `*.{ts,tsx,js,jsx,mjs}` 文件运行 `eslint --max-warnings=0`
|
||||||
- **UI 库**: Material UI (MUI)
|
|
||||||
- **状态管理**: React Hooks
|
|
||||||
- **路由**: React Router DOM
|
|
||||||
|
|
||||||
### 目录结构
|
### CI(GitHub Actions)
|
||||||
|
|
||||||
|
1. `setup` — 安装依赖
|
||||||
|
2. 并行 `lint`、`typecheck`(含 `wxt prepare`)、`test`
|
||||||
|
3. `build` — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── components/ # 可复用 UI 组件
|
src/ # 源代码根目录
|
||||||
│ ├── CopyButton.tsx # 复制按钮组件
|
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||||
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
|
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
|
||||||
│ ├── Navbar.tsx # 导航栏组件
|
layout/ # 应用壳层布局(TopBar 导航、搜索、主题切换)
|
||||||
│ ├── RoutePersistence.tsx # 路由持久化组件
|
pages/ # 功能页面组件 (懒加载)
|
||||||
│ ├── TimestampExecution.tsx # 时间戳执行组件
|
components/ # 可复用 UI 组件
|
||||||
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
|
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
providers/ # React Context (Router, Theme 等)
|
||||||
│ ├── background.ts # 后台脚本(主进程)
|
hooks/ # 自定义 React Hooks
|
||||||
│ ├── content.ts # 内容脚本(注入到页面)
|
utils/ # 工具函数与服务抽象
|
||||||
│ └── popup/ # 扩展弹窗界面
|
types/ # TypeScript 类型声明
|
||||||
│ ├── App.tsx # 弹窗主应用
|
lib/ # 通用工具函数(cn、utils)及数据生成器定义
|
||||||
│ ├── main.tsx # 弹窗入口
|
workers/ # Web Worker(数据生成等耗时任务)
|
||||||
│ └── pages/ # 弹窗页面
|
spec/ # 功能规格、修复方案与验收标准(见 spec/README.md)
|
||||||
│ ├── TestPage.tsx # 测试页面
|
public/ # 静态资源(图标等)
|
||||||
│ └── TimestampPage.tsx # 时间戳工具页面
|
.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑)
|
||||||
├── utils/ # 工具函数
|
.output/ # 生产构建输出目录
|
||||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
|
||||||
│ ├── dayjs.ts # 日期处理工具
|
|
||||||
│ └── messages.tsx # 消息通信工具
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # 存储相关类型
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心功能实现
|
### layout/
|
||||||
|
|
||||||
#### 1. 时间戳转换工具
|
应用壳层,与 popup / sidepanel / tab 入口绑定。当前含 `TopBar/`(搜索、主题切换、返回导航、「在标签页打开」),遵循与 `pages/` 相同的 UI + Hook 模式。详见 [layout/README.md](./src/layout/README.md)。
|
||||||
|
|
||||||
- 位置: `components/` 目录下的时间戳相关组件
|
### spec/
|
||||||
- 依赖: dayjs 库进行日期处理
|
|
||||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式
|
|
||||||
|
|
||||||
#### 2. 通信系统
|
功能规格与验收标准文档,重大改动前优先查阅。索引见 [spec/README.md](./spec/README.md)。
|
||||||
|
|
||||||
- 位置: `utils/messages.tsx`
|
### 页面组件模式
|
||||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
|
||||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
|
|
||||||
|
|
||||||
#### 3. 数据存储
|
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
|
||||||
|
|
||||||
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
|
```
|
||||||
|
src/pages/FeatureName/
|
||||||
### 关键配置文件
|
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局)
|
||||||
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
#### wxt.config.ts
|
├── constants.ts # 常量定义(可选,≥3 个常量时创建)
|
||||||
|
└── __tests__/
|
||||||
- 配置 WXT 框架参数
|
└── index.test.tsx
|
||||||
- 启用 React 模块
|
|
||||||
- 配置浏览器扩展权限
|
|
||||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
|
||||||
|
|
||||||
#### manifest 权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage', // 存储权限
|
|
||||||
'unlimitedStorage', // 无限制存储
|
|
||||||
'clipboardWrite', // 剪贴板写入
|
|
||||||
'activeTab', // 当前标签页
|
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'debugger', // 调试器
|
|
||||||
],
|
|
||||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发注意事项
|
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
|
||||||
|
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||||
|
- 子组件可以独立调用全局 Hook
|
||||||
|
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
||||||
|
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
||||||
|
|
||||||
### 扩展入口点
|
### 测试数据生成器模块
|
||||||
|
|
||||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
```
|
||||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
src/pages/TestDataGenerator/
|
||||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
├── 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 转换、下载、剪贴板)
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
src/lib/generators/ # 内置生成器定义(个人信息、企业、技术、基础类型)
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
|
||||||
|
|
||||||
### 代码质量
|
src/workers/
|
||||||
|
└── generator.worker.ts # 数据生成 Web Worker
|
||||||
|
|
||||||
- 使用 ESLint 进行代码检查
|
src/types/
|
||||||
- Husky 用于 Git 钩子管理
|
└── testDataGenerator.ts # 类型定义(FieldConfig, DataRule, GeneratorDefinition 等)
|
||||||
- Lint-staged 确保暂存文件符合规范
|
```
|
||||||
|
|
||||||
|
## 关键架构决策
|
||||||
|
|
||||||
|
**路由**: 不使用 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`),避免默认值覆盖已有数据。
|
||||||
|
|
||||||
|
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`。
|
||||||
|
|
||||||
|
**路径别名**: `@/` 映射到 `src/` 目录(已在 `.wxt/tsconfig.json` 和 `vitest.config.ts` 中配置)。项目根目录使用 `@@/`。
|
||||||
|
|
||||||
|
**浏览器兼容**: 优先使用 `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,171 +1 @@
|
|||||||
# CLAUDE.md
|
@AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
|
|
||||||
|
|
||||||
## 核心命令
|
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
|
||||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
|
||||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
|
||||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
|
||||||
- `npm run zip` - 打包 Chrome 扩展
|
|
||||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
|
||||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
|
||||||
|
|
||||||
### 依赖与准备
|
|
||||||
|
|
||||||
- `npm install` - 安装依赖
|
|
||||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
|
||||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- GitHub Actions 配置: `.github/workflows/node.js.yml`
|
|
||||||
- 在 main 分支推送或 PR 时触发
|
|
||||||
- 使用 Node.js 22.x 运行 build
|
|
||||||
- 测试命令当前被注释(项目暂无测试)
|
|
||||||
|
|
||||||
## 项目架构
|
|
||||||
|
|
||||||
### 技术栈
|
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
|
||||||
- **前端**: React 19 + TypeScript
|
|
||||||
- **UI 库**: Material UI (MUI)
|
|
||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
|
||||||
- **通信**: @webext-core/messaging
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
|
|
||||||
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
|
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
|
||||||
│ │ ├── App.tsx # 弹窗主应用
|
|
||||||
│ │ ├── main.tsx # 弹窗入口
|
|
||||||
│ │ ├── index.html # 弹窗 HTML
|
|
||||||
│ │ └── pages/ # 弹窗页面
|
|
||||||
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
|
|
||||||
│ └── options/ # 选项页面(当前为静态 HTML)
|
|
||||||
│ └── index.html # 选项页 HTML
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
|
|
||||||
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
|
|
||||||
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging)
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # StorageSchema 类型定义
|
|
||||||
├── constants/ # 常量定义(当前为空)
|
|
||||||
└── public/ # 静态资源
|
|
||||||
```
|
|
||||||
|
|
||||||
### 核心功能
|
|
||||||
|
|
||||||
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
|
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
|
||||||
- 时间戳 → 日期时间转换
|
|
||||||
- 日期时间 → 时间戳转换
|
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
|
||||||
- 一键复制功能
|
|
||||||
- 输入验证和错误提示
|
|
||||||
|
|
||||||
### 扩展入口点
|
|
||||||
|
|
||||||
- **后台脚本** (`entrypoints/background.ts`):
|
|
||||||
- 监听扩展安装/更新事件
|
|
||||||
- 自动向所有有效标签页注入内容脚本
|
|
||||||
- 过滤受限协议(chrome://, about:// 等)
|
|
||||||
|
|
||||||
- **内容脚本** (`entrypoints/content.ts`):
|
|
||||||
- 匹配所有 URL (`<all_urls>`)
|
|
||||||
- 在文档开始时运行
|
|
||||||
- 当前为占位符,无实际逻辑
|
|
||||||
|
|
||||||
- **弹窗** (`entrypoints/popup/`):
|
|
||||||
- 主入口显示 TimestampPage
|
|
||||||
- 提供时间戳转换的完整功能
|
|
||||||
|
|
||||||
- **选项页** (`entrypoints/options/`):
|
|
||||||
- 当前为静态 HTML 页面
|
|
||||||
- 可扩展为设置界面
|
|
||||||
|
|
||||||
### 数据存储
|
|
||||||
|
|
||||||
使用 Chrome Storage API 进行持久化存储:
|
|
||||||
|
|
||||||
- 类型安全的封装 (`utils/chromeStorage.ts`)
|
|
||||||
- 基于接口定义的 Schema (`types/storage.d.ts`)
|
|
||||||
- 当前支持的存储键:
|
|
||||||
- `app/lastRoute`: 上次访问的路由
|
|
||||||
- `app/theme`: 主题设置
|
|
||||||
|
|
||||||
### 消息通信
|
|
||||||
|
|
||||||
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
|
|
||||||
|
|
||||||
- 定义在 `utils/messages.tsx`
|
|
||||||
- 当前 ProtocolMap 为空(预留接口)
|
|
||||||
|
|
||||||
### 关键配置文件
|
|
||||||
|
|
||||||
#### wxt.config.ts
|
|
||||||
|
|
||||||
- 启用 React 模块 (`@wxt-dev/module-react`)
|
|
||||||
- 配置 manifest 权限和 host_permissions
|
|
||||||
- 使用 Terser 压缩(强制 ASCII 编码)
|
|
||||||
- 配置图标和选项页
|
|
||||||
|
|
||||||
#### manifest 权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage', // Chrome Storage
|
|
||||||
'unlimitedStorage', // 无限制存储
|
|
||||||
'clipboardWrite', // 剪贴板写入(复制功能)
|
|
||||||
'activeTab', // 当前标签页访问
|
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'debugger', // 调试器权限
|
|
||||||
],
|
|
||||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
|
||||||
|
|
||||||
## 开发注意事项
|
|
||||||
|
|
||||||
### 浏览器兼容性
|
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
|
||||||
|
|
||||||
### 代码质量
|
|
||||||
|
|
||||||
- 使用 ESLint 进行代码检查(零警告)
|
|
||||||
- Husky 用于 Git 钩子管理
|
|
||||||
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier)
|
|
||||||
- Prettier 用于代码格式化
|
|
||||||
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
|
|
||||||
|
|
||||||
### TypeScript 配置
|
|
||||||
|
|
||||||
- 严格模式开启(`strict: true`)
|
|
||||||
- `noImplicitAny` 设置为 `false`(允许隐式 any)
|
|
||||||
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`)
|
|
||||||
- 模块解析模式:Bundler
|
|
||||||
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
|
|
||||||
|
|
||||||
### 项目历史
|
|
||||||
|
|
||||||
近期重构(根据 git 历史):
|
|
||||||
|
|
||||||
- 移除了录制回放功能
|
|
||||||
- 移除了测试页面
|
|
||||||
- 精简为单页面时间戳工具
|
|
||||||
- 将 storage 工具类重命名为 storageUtil
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# Testing Tools 项目指南
|
|
||||||
|
|
||||||
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
|
|
||||||
|
|
||||||
## 1. 项目概览
|
|
||||||
|
|
||||||
- **名称**: Testing Tools
|
|
||||||
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
|
||||||
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
|
|
||||||
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
|
|
||||||
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
|
|
||||||
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
|
|
||||||
|
|
||||||
## 2. 项目结构
|
|
||||||
|
|
||||||
```text
|
|
||||||
├── .github/ # CI/CD 工作流
|
|
||||||
├── .husky/ # Git Hooks (pre-commit linting)
|
|
||||||
├── assets/ # 静态资源 (SVG 等)
|
|
||||||
├── components/ # 复用 UI 组件
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台 Service Worker 逻辑
|
|
||||||
│ ├── content.ts # 内容脚本注入逻辑
|
|
||||||
│ ├── popup/ # 扩展弹出层 (主要功能区)
|
|
||||||
│ └── options/ # 扩展选项页面
|
|
||||||
├── types/ # 全局 TypeScript 类型声明
|
|
||||||
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
|
|
||||||
├── wxt.config.ts # WXT 框架与 Manifest 配置
|
|
||||||
└── package.json # 依赖管理与脚本
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 开发规范与风格约定
|
|
||||||
|
|
||||||
### 3.1 UI 设计语言
|
|
||||||
|
|
||||||
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
|
|
||||||
- **MUI 定制**:
|
|
||||||
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
|
|
||||||
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`。
|
|
||||||
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
|
|
||||||
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
|
|
||||||
- **布局**: 优先使用 `Stack` 和 `Box` 进行布局,确保自上而下的操作流顺畅。
|
|
||||||
|
|
||||||
### 3.2 技术选型惯例
|
|
||||||
|
|
||||||
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
|
|
||||||
- **状态管理**: 优先使用 React 原生 `useState` 和 `useMemo`。
|
|
||||||
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
|
|
||||||
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
|
|
||||||
|
|
||||||
## 4. 关键指令
|
|
||||||
|
|
||||||
### 4.1 开发与调试
|
|
||||||
|
|
||||||
- `npm run dev`: 启动 Chrome 扩展开发模式。
|
|
||||||
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
|
|
||||||
|
|
||||||
### 4.2 构建与检查
|
|
||||||
|
|
||||||
- `npm run build`: 构建生产版本。
|
|
||||||
- `npm run compile`: TypeScript 类型检查。
|
|
||||||
- `npm run lint`: ESLint 代码风格检查。
|
|
||||||
|
|
||||||
## 5. 权限与清单 (Manifest)
|
|
||||||
|
|
||||||
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`。
|
|
||||||
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
|
|
||||||
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
|
|
||||||
|
|
||||||
## 6. 维护者提示
|
|
||||||
|
|
||||||
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Testing Tools
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -1,121 +1,166 @@
|
|||||||
# Testing Tools Browser Extension
|
# Testing Tools Browser Extension
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。
|
这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具.
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Testing Tools 是一个轻量级的浏览器扩展,提供实用的时间戳转换功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持.
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
|
### Dashboard 首页
|
||||||
|
|
||||||
|
- **工具导航**: 快速访问所有可用工具.
|
||||||
|
- **个性化定制**: 支持自定义工具的排序和可见性.
|
||||||
|
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
|
||||||
|
|
||||||
### 时间戳转换工具
|
### 时间戳转换工具
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
- **实时显示**: 毫秒级精度显示当前系统时间.
|
||||||
- 日期与时间戳之间的双向转换
|
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
|
||||||
- 一键复制转换结果
|
- **快捷操作**: 一键复制转换结果,支持多种格式.
|
||||||
- 输入验证和错误提示
|
|
||||||
|
### 存储清理工具
|
||||||
|
|
||||||
|
- **智能识别**: 自动检测并显示当前活动标签页的域名.
|
||||||
|
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
|
||||||
|
- **细粒度控制**: 可根据需要选择特定的清理项.
|
||||||
|
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
|
||||||
|
|
||||||
|
### 文本统计工具
|
||||||
|
|
||||||
|
- **实时分析**: 键入即统计,无需额外操作.
|
||||||
|
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
|
||||||
|
- **性能优化**: 采用高性能分词算法,支持大文本处理.
|
||||||
|
|
||||||
|
### JWT 解析工具
|
||||||
|
|
||||||
|
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
|
||||||
|
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
|
||||||
|
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
||||||
|
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
||||||
|
|
||||||
|
### 右键恢复工具
|
||||||
|
|
||||||
|
- **当前页面检测**: 自动识别当前活动标签页的域名.
|
||||||
|
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
|
||||||
|
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
|
||||||
|
|
||||||
|
### 二维码工具
|
||||||
|
|
||||||
|
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||||
|
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||||
|
|
||||||
|
### JSON 工具
|
||||||
|
|
||||||
|
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
|
||||||
|
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
|
||||||
|
|
||||||
|
### Base64 转换器
|
||||||
|
|
||||||
|
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
|
||||||
|
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||||
|
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||||
|
|
||||||
|
### 测试数据生成器
|
||||||
|
|
||||||
|
- **可视化字段配置**: 通过 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
|
- **前端**: React 19 + TypeScript
|
||||||
- **UI 库**: Material UI
|
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||||
|
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||||
|
- **UI 文案**: 组件内直接使用中文字符串;功能名称与描述定义在 `src/config/features.tsx`
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
|
- **测试**: Vitest + Testing Library
|
||||||
|
- **拖拽排序**: @dnd-kit/core + @dnd-kit/sortable
|
||||||
|
- **异步生成**: Web Worker (批量数据生成)
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
├── src/ # 源代码根目录
|
||||||
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
|
│ ├── components/ # 可复用 React 组件
|
||||||
│ ├── options/ # 选项页面
|
│ ├── config/ # 应用配置
|
||||||
│ ├── background.ts # 后台脚本
|
│ │ └── features.tsx # 功能定义与路由映射
|
||||||
│ └── content.ts # 内容脚本
|
│ ├── entrypoints/ # 扩展程序入口点
|
||||||
├── utils/ # 工具函数(存储、日期处理、消息通信)
|
│ │ ├── popup/ # 点击图标弹出的主界面
|
||||||
├── types/ # TypeScript 类型定义
|
│ │ ├── sidepanel/ # 浏览器侧边栏集成
|
||||||
├── public/ # 静态资源
|
│ │ ├── background.ts # 后台 Service Worker
|
||||||
├── wxt.config.ts # WXT 配置文件
|
│ │ └── content.ts # 网页注入脚本
|
||||||
├── package.json # 项目依赖和脚本
|
│ ├── pages/ # 各功能模块的页面组件
|
||||||
└── README.md # 项目说明文档
|
│ ├── workers/ # Web Worker (数据生成等耗时任务)
|
||||||
|
│ ├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||||
|
│ ├── hooks/ # 自定义 React Hooks
|
||||||
|
│ ├── utils/ # 工具函数与服务抽象
|
||||||
|
│ ├── types/ # TypeScript 类型声明
|
||||||
|
│ └── lib/ # 通用工具函数与生成器库 (cn, utils, generators 等)
|
||||||
|
├── public/ # 静态资源 (图标等)
|
||||||
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
|
└── package.json # 项目元数据与依赖管理
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发环境要求
|
## 开发与部署
|
||||||
|
|
||||||
- Node.js >= 18
|
### 开发环境要求
|
||||||
- npm 或 yarn
|
|
||||||
|
|
||||||
## 安装与运行
|
- Node.js >= 18.x
|
||||||
|
- npm 或 pnpm
|
||||||
|
|
||||||
### 1. 安装依赖
|
### 常用命令
|
||||||
|
|
||||||
```bash
|
| 命令 | 说明 |
|
||||||
npm install
|
| ----------------------- | --------------------------------- |
|
||||||
```
|
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||||
|
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||||
|
| `npm run build` | 构建 Chrome 生产版本 |
|
||||||
|
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||||
|
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||||
|
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||||
|
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||||
|
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||||
|
| `npm run test` | 运行单元测试 |
|
||||||
|
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||||
|
|
||||||
### 2. 开发模式
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
```bash
|
### 自动化流程
|
||||||
# Chrome 浏览器
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||||
npm run dev:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 构建生产版本
|
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||||
|
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
|
||||||
npm run build:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 打包分发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run zip
|
|
||||||
|
|
||||||
# Firefox
|
|
||||||
npm run zip:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 其他命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run compile # TypeScript 类型检查
|
|
||||||
npm run lint # ESLint 代码检查
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限说明
|
## 权限说明
|
||||||
|
|
||||||
扩展请求以下权限:
|
本扩展根据功能需要申请了以下权限:
|
||||||
|
|
||||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||||
- `debugger` - 调试器权限
|
- `cookies`: 管理和清理网站 Cookie.
|
||||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
|
||||||
## 主要依赖
|
## 浏览器支持
|
||||||
|
|
||||||
- `react`, `react-dom` - 前端框架
|
- Chrome (及其它 Chromium 内核浏览器)
|
||||||
- `@mui/material` - UI 组件库
|
- Firefox
|
||||||
- `dayjs` - 日期处理
|
|
||||||
- `@webext-core/messaging` - 扩展消息通信
|
|
||||||
|
|
||||||
## 贡献指南
|
|
||||||
|
|
||||||
1. Fork 项目
|
|
||||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
|
||||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
此项目为私有项目 (private: true),仅供内部使用。
|
基于 [MIT License](LICENSE) 开源.
|
||||||
|
|||||||
@@ -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/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_本文档随项目迭代更新。新增组件或修改视觉风格时,请同步更新此文档。_
|
||||||
@@ -1,880 +0,0 @@
|
|||||||
# Storage Cleaner Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add a storage cleaner feature to the browser extension popup that allows users to clear localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, andress Workers for the current page.
|
|
||||||
|
|
||||||
**Architecture:** Add new StorageCleanerPage component with tab switching in the popup, using chrome.cookies API for cookies and script injection for other storage types. User preferences are persisted using Chrome Storage.
|
|
||||||
|
|
||||||
**Tech Stack:** React 19 + TypeScript, Material UI, Chrome Extension APIs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
entrypoints/popup/
|
|
||||||
├── App.tsx (modify: add tab switching)
|
|
||||||
└── pages/
|
|
||||||
├── TimestampPage.tsx (no change)
|
|
||||||
└── StorageCleanerPage.tsx (create: new storage cleaner page)
|
|
||||||
|
|
||||||
types/
|
|
||||||
└── storage.d.ts (modify: add storage cleaner types)
|
|
||||||
|
|
||||||
utils/
|
|
||||||
└── storageCleaner.ts (create: storage cleaning utilities)
|
|
||||||
|
|
||||||
wxt.config.ts (modify: add cookies permission)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Add TypeScript Types for Storage Cleaner
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `types/storage.d.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add storage cleaner types to StorageSchema and interfaces**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface StorageSchema {
|
|
||||||
'app/lastRoute': string;
|
|
||||||
'app/theme': string;
|
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerPreferences {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
selectedTypes: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerOptions {
|
|
||||||
localStorage: boolean;
|
|
||||||
sessionStorage: boolean;
|
|
||||||
indexedDB: boolean;
|
|
||||||
cookies: boolean;
|
|
||||||
cacheStorage: boolean;
|
|
||||||
serviceWorkers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorageCleanResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CleaningResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
localStorage?: StorageCleanResult;
|
|
||||||
sessionStorage?: StorageCleanResult;
|
|
||||||
indexedDB?: StorageCleanResult;
|
|
||||||
cookies?: StorageCleanResult;
|
|
||||||
cacheStorage?: StorageCleanResult;
|
|
||||||
serviceWorkers?: StorageCleanResult;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit TypeScript types**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add types/storage.d.ts
|
|
||||||
git commit -m "feat: add TypeScript types for storage cleaner"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Add Cookies Permission to Manifest
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `wxt.config.ts:10-18`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add 'cookies' permission to manifest**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage',
|
|
||||||
'unlimitedStorage',
|
|
||||||
'clipboardWrite',
|
|
||||||
'activeTab',
|
|
||||||
'scripting',
|
|
||||||
'tabs',
|
|
||||||
'debugger',
|
|
||||||
'cookies', // Add this line
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Test build to ensure manifest is valid**
|
|
||||||
|
|
||||||
Run: `npm run compile`
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit manifest changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add wxt.config.ts
|
|
||||||
git commit -m "feat: add cookies permission to manifest"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Create Storage Cleaning Utilities
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `utils/storageCleaner.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create storage cleaning utility file with helper functions**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
|
|
||||||
|
|
||||||
const RESTRICTED_PROTOCOLS = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
'file:',
|
|
||||||
'data:',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export async function getCurrentTab() {
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
return tab;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRestrictedUrl(url?: string): boolean {
|
|
||||||
if (!url) return true;
|
|
||||||
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const cookies = await chrome.cookies.getAll({ url });
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
await chrome.cookies.remove({
|
|
||||||
url,
|
|
||||||
name: cookie.name,
|
|
||||||
storeId: cookie.storeId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { success: true, count: cookies.length };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
const count = localStorage.length;
|
|
||||||
localStorage.clear();
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
const count = sessionStorage.length;
|
|
||||||
sessionStorage.clear();
|
|
||||||
return { count };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number } | { error: string }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: () => {
|
|
||||||
if (typeof indexedDB.databases === 'function') {
|
|
||||||
return indexedDB.databases().then(async (databases) => {
|
|
||||||
let count = 0;
|
|
||||||
for (const db of databases) {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
|
||||||
deleteReq.onblocked = () => {
|
|
||||||
console.warn('IndexedDB delete blocked:', db.name);
|
|
||||||
};
|
|
||||||
deleteReq.onsuccess = () => resolve();
|
|
||||||
deleteReq.onerror = () => reject();
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return { count };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
if ('error' in result.result) {
|
|
||||||
return { success: false, error: result.result.error };
|
|
||||||
}
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: async () => {
|
|
||||||
if ('caches' in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
for (const name of cacheNames) {
|
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return { count: cacheNames.length };
|
|
||||||
}
|
|
||||||
return { count: 0 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
|
||||||
try {
|
|
||||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
|
||||||
target: { tabId },
|
|
||||||
func: async () => {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister();
|
|
||||||
}
|
|
||||||
return { count: registrations.length };
|
|
||||||
}
|
|
||||||
return { count: 0 };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (result.result) {
|
|
||||||
return { success: true, count: result.result.count };
|
|
||||||
}
|
|
||||||
return { success: false, error: 'No result returned' };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearStorage(
|
|
||||||
tabId: number,
|
|
||||||
url: string,
|
|
||||||
options: StorageCleanerOptions,
|
|
||||||
): Promise<CleaningResult> {
|
|
||||||
const result: CleaningResult = { success: true };
|
|
||||||
|
|
||||||
if (options.localStorage) {
|
|
||||||
result.localStorage = await injectClearLocalStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.sessionStorage) {
|
|
||||||
result.sessionStorage = await injectClearSessionStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.indexedDB) {
|
|
||||||
result.indexedDB = await injectClearIndexedDB(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cookies) {
|
|
||||||
result.cookies = await clearCookies(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.cacheStorage) {
|
|
||||||
result.cacheStorage = await injectClearCacheStorage(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.serviceWorkers) {
|
|
||||||
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any operation failed
|
|
||||||
const failures = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (failures.length > 0) {
|
|
||||||
result.success = false;
|
|
||||||
result.error = '部分清理失败';
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCleaningResult(result: CleaningResult): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (result.localStorage?.success) {
|
|
||||||
parts.push(`${result.localStorage.count} 个 localStorage`);
|
|
||||||
}
|
|
||||||
if (result.sessionStorage?.success) {
|
|
||||||
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
|
|
||||||
}
|
|
||||||
if (result.indexedDB?.success) {
|
|
||||||
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
|
|
||||||
}
|
|
||||||
if (result.cookies?.success) {
|
|
||||||
parts.push(`${result.cookies.count} 个 Cookies`);
|
|
||||||
}
|
|
||||||
if (result.cacheStorage?.success) {
|
|
||||||
parts.push(`${result.cacheStorage.count} 个 Cache`);
|
|
||||||
}
|
|
||||||
if (result.serviceWorkers?.success) {
|
|
||||||
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return '该页面没有可清理的存储数据';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `清理了 ${parts.join(', ')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyResult(result: CleaningResult): boolean {
|
|
||||||
const values = Object.values(result).filter(
|
|
||||||
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
|
|
||||||
);
|
|
||||||
return values.length === 0;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit storage cleaning utilities**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add utils/storageCleaner.ts
|
|
||||||
git commit -m "feat: add storage cleaning utility functions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Create StorageCleanerPage Component
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create StorageCleanerPage component with UI and logic**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
FormControlLabel,
|
|
||||||
Alert,
|
|
||||||
Snackbar,
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
|
||||||
import {
|
|
||||||
getCurrentTab,
|
|
||||||
isRestrictedUrl,
|
|
||||||
clearStorage,
|
|
||||||
formatCleaningResult,
|
|
||||||
isEmptyResult,
|
|
||||||
} from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load tab info and user preferences
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInfo = async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.url) {
|
|
||||||
setError('无法获取当前标签页');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestrictedUrl(tab.url)) {
|
|
||||||
setError('存储清理功能不支持此页面');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDomain(new URL(tab.url).hostname);
|
|
||||||
|
|
||||||
// Load user preferences
|
|
||||||
const prefs = await storageUtil.get(
|
|
||||||
'storageCleaner/preferences',
|
|
||||||
DEFAULT_PREFERENCES,
|
|
||||||
);
|
|
||||||
setAutoRefresh(prefs.autoRefresh);
|
|
||||||
setOptions(prefs.selectedTypes);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
|
||||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClean = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.id || !tab.url) {
|
|
||||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
|
||||||
setResult(cleaningResult);
|
|
||||||
|
|
||||||
// Save user preferences
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh,
|
|
||||||
selectedTypes: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto refresh if enabled
|
|
||||||
if (autoRefresh && cleaningResult.success) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowConfirm(false);
|
|
||||||
}
|
|
||||||
}, [options, autoRefresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
if (tab?.id) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
<Alert severity="error" icon={<WarningIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
当前页面: {domain || '加载中...'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Storage Type Options */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
|
||||||
选择要清理的存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.localStorage}
|
|
||||||
onChange={() => handleOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="localStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
onChange={() => handleOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="sessionStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.indexedDB}
|
|
||||||
onChange={() => handleOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="IndexedDB"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cookies}
|
|
||||||
onChange={() => handleOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cookies"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
onChange={() => handleOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cache Storage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
onChange={() => handleOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Service Workers"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Auto Refresh Option */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="清理完成后自动刷新页面"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '清理中...' : '清理'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
|
||||||
{result && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
{!autoRefresh && result.success && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
{showConfirm && (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6">确认清理</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
|
||||||
将清理以下存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
{options.localStorage && (
|
|
||||||
<Typography variant="body2">- localStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.sessionStorage && (
|
|
||||||
<Typography variant="body2">- sessionStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
|
||||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
|
||||||
{options.cacheStorage && (
|
|
||||||
<Typography variant="body2">- Cache Storage</Typography>
|
|
||||||
)}
|
|
||||||
{options.serviceWorkers && (
|
|
||||||
<Typography variant="body2">- Service Workers</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textAlign: 'center', mb: 1 }}
|
|
||||||
>
|
|
||||||
此操作不可撤销。
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" color="error" onClick={handleClean}>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert severity="info" variant="filled">
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Commit StorageCleanerPage component**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add entrypoints/popup/pages/StorageCleanerPage.tsx
|
|
||||||
git commit -m "feat: add StorageCleanerPage component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Update App.tsx with Tab Switching
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: `entrypoints/popup/App.tsx`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add tab switching logic to App.tsx**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Box, Button } from '@mui/material';
|
|
||||||
import TimestampPage from './pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
type PageType = 'timestamp' | 'storageCleaner';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('timestamp')}
|
|
||||||
>
|
|
||||||
时间戳
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('storageCleaner')}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run type check**
|
|
||||||
|
|
||||||
Run: `npm run compile`
|
|
||||||
Expected: No TypeScript errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit App.tsx changes**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add entrypoints/popup/App.tsx
|
|
||||||
git commit -m "feat: add tab switching to App component"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Build and Test
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- No file changes
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build the extension**
|
|
||||||
|
|
||||||
Run: `npm run build`
|
|
||||||
Expected: Build succeeds with no errors
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run lint check**
|
|
||||||
|
|
||||||
Run: `npm run lint`
|
|
||||||
Expected: No linting errors
|
|
||||||
|
|
||||||
- [ ] **Step 3: Load extension in Chrome for manual testing**
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
|
|
||||||
1. Open Chrome and navigate to `chrome://extensions/`
|
|
||||||
2. Enable Developer Mode
|
|
||||||
3. Click "Load unpacked"
|
|
||||||
4. Select `.output/chrome-mv3` directory
|
|
||||||
5. Test on a regular web page (e.g., example.com)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit successful implementation**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git commit --allow-empty -m "feat: complete storage cleaner feature implementation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
After implementation, verify:
|
|
||||||
|
|
||||||
- [ ] Tab switching works between timestamp and storage cleaner
|
|
||||||
- [ ] Current domain displays correctly
|
|
||||||
- [ ] All storage type checkboxes toggle correctly
|
|
||||||
- [ ] Auto refresh checkbox persists across sessions
|
|
||||||
- [ ] Clear confirmation dialog appears
|
|
||||||
- [ ] Confirmation dialog shows selected storage types
|
|
||||||
- [ ] localStorage clears successfully
|
|
||||||
- [ ] sessionStorage clears successfully
|
|
||||||
- [ ] IndexedDB clears successfully (or shows error if unavailable)
|
|
||||||
- [ ] Cookies clear successfully
|
|
||||||
- [ ] Clear httponly and secure cookies
|
|
||||||
- [ ] Cache Storage clears successfully
|
|
||||||
- [ ] Service Workers unregister successfully
|
|
||||||
- [ ] Result message displays correctly
|
|
||||||
- [ ] Empty state shows friendly message
|
|
||||||
- [ ] Auto refresh works
|
|
||||||
- [ ] Manual refresh button appears when auto-refresh is off
|
|
||||||
- [ ] Restricted pages show error message
|
|
||||||
- [ ] Snackbar notifications appear correctly
|
|
||||||
- [ ] Test on localhost
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Rollback Plan
|
|
||||||
|
|
||||||
If issues occur during testing:
|
|
||||||
|
|
||||||
1. Revert to before implementation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git reset --hard <commit-before-start>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Or revert specific files:
|
|
||||||
```bash
|
|
||||||
git checkout HEAD -- types/storage.d.ts wxt.config.ts utils/storageCleaner.ts entrypoints/popup/App.tsx entrypoints/popup/pages/StorageCleanerPage.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- The popup closes automatically when the page is refreshed - this is expected behavior
|
|
||||||
- IndexedDB.databases() may not be available in all browser versions; the fallback handles this
|
|
||||||
- Chrome Cookies API requires explicit permission, which is added to the manifest
|
|
||||||
- User preferences are persisted using the existing chromeStorage.ts utility
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
# 存储清理功能设计文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
- 提供便捷的页面存储清理功能
|
|
||||||
- 支持多种存储类型清理
|
|
||||||
- 提供清理结果反馈
|
|
||||||
- 支持清理后自动刷新页面
|
|
||||||
|
|
||||||
## 架构设计
|
|
||||||
|
|
||||||
### 组件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
entrypoints/popup/pages/
|
|
||||||
├── TimestampPage.tsx (现有:时间戳转换页面)
|
|
||||||
└── StorageCleanerPage.tsx (新增:存储清理页面)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 页面布局
|
|
||||||
|
|
||||||
在弹窗中添加标签页切换功能,用户可以在时间戳转换和存储清理之间切换。
|
|
||||||
|
|
||||||
**路由实现方案:**
|
|
||||||
|
|
||||||
使用简单的状态管理进行页面切换:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// App.tsx
|
|
||||||
type PageType = 'timestamp' | 'storageCleaner';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('timestamp')}
|
|
||||||
>
|
|
||||||
时间戳
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => setCurrentPage('storageCleaner')}
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
>
|
|
||||||
存储清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 用户界面设计
|
|
||||||
|
|
||||||
### 页面组成
|
|
||||||
|
|
||||||
1. **头部区域**
|
|
||||||
- 标题:"存储清理"
|
|
||||||
- 当前域名显示(自动从活动标签页获取)
|
|
||||||
|
|
||||||
2. **存储类型选择区域**
|
|
||||||
- 勾选框:localStorage
|
|
||||||
- 勾选框:sessionStorage
|
|
||||||
- 勾选框:IndexedDB
|
|
||||||
- 勾选框:Cookies
|
|
||||||
- 勾选框:Cache Storage
|
|
||||||
- 勾选框:Service Workers
|
|
||||||
|
|
||||||
3. **自动刷新选项**
|
|
||||||
- 复选框:清理完成后自动刷新页面(默认勾选)
|
|
||||||
|
|
||||||
4. **操作区域**
|
|
||||||
- 清理按钮
|
|
||||||
|
|
||||||
5. **结果显示区域**
|
|
||||||
- 清理成功/失败提示
|
|
||||||
- 清理详情统计(如:"清理了 5 个 localStorage, 3 个 cookies")
|
|
||||||
- 刷新页面按钮(当未勾选自动刷新时显示)
|
|
||||||
|
|
||||||
## 技术实现细节
|
|
||||||
|
|
||||||
### 获取当前标签页域名
|
|
||||||
|
|
||||||
使用 Chrome Tabs API 获取当前活动标签页,并过滤受限页面:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
|
|
||||||
// 检查受限页面
|
|
||||||
const restrictedProtocols = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
'file:',
|
|
||||||
'data:',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!tab?.url || restrictedProtocols.some((p) => tab.url!.startsWith(p))) {
|
|
||||||
throw new Error('存储清理功能不支持此页面');
|
|
||||||
}
|
|
||||||
|
|
||||||
const domain = new URL(tab.url).hostname;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 清理 Cookies(使用 chrome.cookies API)
|
|
||||||
|
|
||||||
在扩展环境中直接执行,不需要注入页面:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const cookies = await chrome.cookies.getAll({ url: tab.url });
|
|
||||||
let count = 0;
|
|
||||||
for (const cookie of cookies) {
|
|
||||||
await chrome.cookies.remove({
|
|
||||||
url: tab.url,
|
|
||||||
name: cookie.name,
|
|
||||||
storeId: cookie.storeId,
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 注入脚本清理其他存储
|
|
||||||
|
|
||||||
使用 `chrome.scripting.executeScript` 注入清理脚本:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const result = await chrome.scripting.executeScript({
|
|
||||||
target: { tabId: tab.id },
|
|
||||||
func: () => {
|
|
||||||
// 清理逻辑在页面上下文中执行
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
需要注入到页面执行的存储清理逻辑:
|
|
||||||
|
|
||||||
#### 清理 localStorage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = localStorage.length;
|
|
||||||
localStorage.clear();
|
|
||||||
return count;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 sessionStorage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const count = sessionStorage.length;
|
|
||||||
sessionStorage.clear();
|
|
||||||
return count;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 IndexedDB
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 检查 indexedDB.databases 方法是否可用
|
|
||||||
if (typeof indexedDB.databases === 'function') {
|
|
||||||
const databases = await indexedDB.databases();
|
|
||||||
let count = 0;
|
|
||||||
for (const db of databases) {
|
|
||||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
|
||||||
deleteReq.onblocked = () => {
|
|
||||||
console.warn('IndexedDB delete blocked:', db.name);
|
|
||||||
};
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
deleteReq.onsuccess = resolve;
|
|
||||||
deleteReq.onerror = reject;
|
|
||||||
});
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
// 降级方案:由于无法获取所有数据库名称,提示返回特殊值表示需要手动操作
|
|
||||||
return { error: 'databases_api_unavailable' };
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 清理 Cache Storage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if ('caches' in window) {
|
|
||||||
const cacheNames = await caches.keys();
|
|
||||||
for (const name of cacheNames) {
|
|
||||||
await caches.delete(name);
|
|
||||||
}
|
|
||||||
return cacheNames.length;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 注销 Service Workers
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
|
||||||
let count = 0;
|
|
||||||
for (const registration of registrations) {
|
|
||||||
await registration.unregister();
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据流
|
|
||||||
|
|
||||||
1. 页面加载时获取当前标签页 URL 并显示域名
|
|
||||||
2. 检查是否为受限页面(chrome://, about:// 等),如果是则显示错误提示
|
|
||||||
3. 用户勾选要清理的存储类型
|
|
||||||
4. 用户选择是否自动刷新页面
|
|
||||||
5. 用户点击清理按钮
|
|
||||||
6. 弹出确认对话框询问用户确认
|
|
||||||
7. 确认后执行清理:
|
|
||||||
- 如果选择 Cookies:直接使用 chrome.cookies API 删除
|
|
||||||
- 其他存储类型:向页面注入清理脚本
|
|
||||||
8. 收集所有清理结果并统计
|
|
||||||
9. 显示清理结果
|
|
||||||
10. 如果勾选"自动刷新"或用户点击"刷新页面"按钮,执行页面刷新
|
|
||||||
|
|
||||||
**注意:** 当触发页面刷新时,popup 会自动关闭。需要在刷新前显示提示信息。
|
|
||||||
|
|
||||||
### 页面刷新
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 显示刷新提示
|
|
||||||
setRefreshing(true);
|
|
||||||
setTimeout(async () => {
|
|
||||||
await chrome.tabs.reload(tab.id);
|
|
||||||
}, 1500); // 1.5秒延迟让用户看到提示信息
|
|
||||||
```
|
|
||||||
|
|
||||||
### 空状态处理
|
|
||||||
|
|
||||||
当所有存储类型清理返回 0 时,显示友好的提示:
|
|
||||||
|
|
||||||
```
|
|
||||||
该页面没有可清理的存储数据
|
|
||||||
```
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
| 错误场景 | 处理方式 |
|
|
||||||
| ------------------------------- | ---------------------------------------- |
|
|
||||||
| 无法获取当前标签页 | 显示错误提示:"无法获取当前标签页" |
|
|
||||||
| 受限页面(chrome://, about://) | 显示错误提示:"存储清理功能不支持此页面" |
|
|
||||||
| 无法访问页面 URL | 显示错误提示:"无法访问此页面" |
|
|
||||||
| IndexedDB onblocked | 显示警告但继续执行其他清理 |
|
|
||||||
| IndexedDB.databases 不可用 | 使用降级方案或提示用户手动清除 |
|
|
||||||
| 清理失败 | 显示具体错误信息 |
|
|
||||||
| Cookies 删除失败 | 记录错误,显示清理失败提示 |
|
|
||||||
| 无权限 | 提示用户刷新扩展或检查权限 |
|
|
||||||
| 脚本注入失败 | 显示错误提示:"无法注入清理脚本" |
|
|
||||||
|
|
||||||
**Popup 生命周期说明:**
|
|
||||||
|
|
||||||
- Popup 在页面失去焦点时会关闭
|
|
||||||
- 刷新页面后 Popup 会自动关闭
|
|
||||||
- 需要在刷新前显示提示:"页面即将刷新,Popup 将关闭"
|
|
||||||
|
|
||||||
## 用户偏好持久化
|
|
||||||
|
|
||||||
使用现有的 `chromeStorage.ts` 工具保存用户偏好:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 保存用户偏好
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh: true, // 默认勾选自动刷新
|
|
||||||
selectedTypes: {
|
|
||||||
// 可以保存用户上次选择的存储类型
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 读取用户偏好
|
|
||||||
const preferences = await storageUtil.get('storageCleaner/preferences', {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限需求
|
|
||||||
|
|
||||||
需要在 manifest 中添加 `cookies` 权限:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage',
|
|
||||||
'unlimitedStorage',
|
|
||||||
'clipboardWrite',
|
|
||||||
'activeTab',
|
|
||||||
'scripting',
|
|
||||||
'tabs',
|
|
||||||
'debugger',
|
|
||||||
'cookies', // 新增
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
## TypeScript 类型定义
|
|
||||||
|
|
||||||
在现有 `types/storage.d.ts` 中添加存储清理相关的类型:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface StorageSchema {
|
|
||||||
'app/lastRoute': string;
|
|
||||||
'app/theme': string;
|
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerPreferences {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
selectedTypes: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageCleanerOptions {
|
|
||||||
localStorage: boolean;
|
|
||||||
sessionStorage: boolean;
|
|
||||||
indexedDB: boolean;
|
|
||||||
cookies: boolean;
|
|
||||||
cacheStorage: boolean;
|
|
||||||
serviceWorkers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StorageCleanResult =
|
|
||||||
| {
|
|
||||||
success: true;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface CleaningResult {
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
localStorage?: StorageCleanResult;
|
|
||||||
sessionStorage?: StorageCleanResult;
|
|
||||||
indexedDB?: StorageCleanResult;
|
|
||||||
cookies?: StorageCleanResult;
|
|
||||||
cacheStorage?: StorageCleanResult;
|
|
||||||
serviceWorkers?: StorageCleanResult;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 测试计划
|
|
||||||
|
|
||||||
1. 测试各种存储类型的单独清理
|
|
||||||
2. 测试同时清理多种存储类型
|
|
||||||
3. 测试自动刷新功能
|
|
||||||
4. 测试手动刷新按钮
|
|
||||||
5. 测试无存储数据时的清理(显示空状态提示)
|
|
||||||
6. 测试无法访问页面的错误处理
|
|
||||||
7. 测试受限页面(chrome://, about://, file://, data://)
|
|
||||||
8. 测试 IndexedDB onblocked 场景
|
|
||||||
9. 测试 httponly 和 secure cookies 清理
|
|
||||||
10. 测试本地开发环境(localhost)
|
|
||||||
11. 测试用户偏好持久化
|
|
||||||
|
|
||||||
## 后续优化
|
|
||||||
|
|
||||||
- 显示清理前的存储使用情况
|
|
||||||
- 支持批量清理多个标签页
|
|
||||||
- 支持自定义域名清理
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 测试数据生成器 - 功能设计文档
|
||||||
|
|
||||||
|
> 版本: 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)。
|
||||||
@@ -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,481 @@
|
|||||||
|
# 规则管理
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
| 功能 | 说明 | 优先级 |
|
||||||
|
| -------- | -------------------- | ------ |
|
||||||
|
| 保存规则 | 将当前配置保存为模板 | P0 |
|
||||||
|
| 规则列表 | 查看所有已保存的规则 | P0 |
|
||||||
|
| 规则搜索 | 按关键词搜索规则 | P1 |
|
||||||
|
| 规则编辑 | 修改已保存的规则 | P0 |
|
||||||
|
| 规则删除 | 删除不需要的规则 | P0 |
|
||||||
|
| 规则加载 | 一键加载规则配置 | P0 |
|
||||||
|
| 规则复制 | 复制规则配置 | P1 |
|
||||||
|
| 规则导出 | 导出为 JSON 文件 | P1 |
|
||||||
|
| 规则导入 | 从 JSON 文件导入 | P1 |
|
||||||
|
|
||||||
|
## 规则数量限制
|
||||||
|
|
||||||
|
- **最大规则数量**: 20 条
|
||||||
|
- **超出限制**: 当规则数量达到 20 条时,保存按钮置灰,提示"已达到最大规则数量"
|
||||||
|
- **解决方式**: 用户需要删除已有规则后才能保存新规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据结构
|
||||||
|
|
||||||
|
### 规则模板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DataRule {
|
||||||
|
id: string; // 唯一标识
|
||||||
|
name: string; // 规则名称
|
||||||
|
description?: string; // 规则描述
|
||||||
|
fields: FieldConfig[]; // 字段配置
|
||||||
|
options: {
|
||||||
|
total: number; // 生成数量
|
||||||
|
format: 'json' | 'csv';
|
||||||
|
defaultEmptyRate: number; // 默认空值率(0-100)
|
||||||
|
};
|
||||||
|
metadata: {
|
||||||
|
createdAt: number; // 创建时间
|
||||||
|
updatedAt: number; // 更新时间
|
||||||
|
lastUsedAt?: number; // 最后使用时间
|
||||||
|
useCount: number; // 使用次数
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface FieldConfig {
|
||||||
|
id: string; // 字段唯一标识
|
||||||
|
name: string; // 字段名
|
||||||
|
generator: string; // 生成器名称
|
||||||
|
params: Record<string, any>; // 生成器参数
|
||||||
|
unique: boolean; // 唯一性约束
|
||||||
|
required: boolean; // 是否必填
|
||||||
|
emptyRate?: number; // 选填字段的空值概率(0-100)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能详情
|
||||||
|
|
||||||
|
### 1. 保存规则
|
||||||
|
|
||||||
|
**触发方式**: 用户点击"保存规则"按钮
|
||||||
|
|
||||||
|
**界面设计**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 保存规则 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 规则信息 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 规则名称: │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [电商用户数据 - 测试用 ]│ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 规则描述 (可选): │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ [用于测试用户注册功能,包含姓名、邮箱、手机号 ]│ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 规则预览 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 字段数量: 4 个 │ │
|
||||||
|
│ │ • name (中文姓名) │ │
|
||||||
|
│ │ • email (邮箱地址) │ │
|
||||||
|
│ │ • phone (手机号) │ │
|
||||||
|
│ │ • age (年龄) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 生成数量: 100 条 │ │
|
||||||
|
│ │ 数据格式: JSON │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [保存] [取消] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**保存逻辑**:
|
||||||
|
|
||||||
|
1. 验证规则名称不为空
|
||||||
|
2. 检查规则数量是否达到上限(20 条)
|
||||||
|
3. 如果达到上限,显示提示"已达到最大规则数量"
|
||||||
|
4. 生成唯一 ID
|
||||||
|
5. 设置创建时间和更新时间
|
||||||
|
6. 初始化使用次数为 0
|
||||||
|
7. 保存到 localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 规则列表
|
||||||
|
|
||||||
|
**界面设计**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 规则管理 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 搜索: [搜索规则名称... ] │
|
||||||
|
│ │
|
||||||
|
│ 规则列表 (已保存 15/20 条) │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ 电商用户数据 - 测试用 │ │ │
|
||||||
|
│ │ │ ─────────────────────────────────────────────────────── │ │ │
|
||||||
|
│ │ │ 用于测试用户注册功能,包含姓名、邮箱、手机号 │ │ │
|
||||||
|
│ │ │ 4 个字段 | 100 条 | JSON │ │ │
|
||||||
|
│ │ │ 2024-01-15 14:30 创建 | 2024-01-20 10:15 最后使用 │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ [加载] [复制] [导出] [编辑] [删除] │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [导入规则] [导出全部] [清空全部] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**列表项信息**:
|
||||||
|
|
||||||
|
- 规则名称
|
||||||
|
- 规则描述
|
||||||
|
- 字段数量、生成数量、数据格式
|
||||||
|
- 创建时间、最后使用时间
|
||||||
|
- 操作按钮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 规则搜索
|
||||||
|
|
||||||
|
**搜索逻辑**:
|
||||||
|
|
||||||
|
- 支持按规则名称搜索
|
||||||
|
- 支持按规则描述搜索
|
||||||
|
- 搜索为模糊匹配,不区分大小写
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
search(keyword: string): DataRule[] {
|
||||||
|
const rules = this.getAll();
|
||||||
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
|
|
||||||
|
return rules.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(lowerKeyword) ||
|
||||||
|
r.description?.toLowerCase().includes(lowerKeyword)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 规则编辑
|
||||||
|
|
||||||
|
**界面设计**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 编辑规则 - 电商用户数据 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 基本信息 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 规则名称: [电商用户数据 - 测试用 ]│ │
|
||||||
|
│ │ 规则描述: [用于测试用户注册功能,包含姓名、邮箱、手机号 ]│ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 字段配置 (可直接编辑) │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 1. name [中文姓名 ▼] [配置] [删除] [上移][下移] │ │
|
||||||
|
│ │ 2. email [邮箱地址 ▼] [配置] [删除] [上移][下移] │ │
|
||||||
|
│ │ 3. phone [手机号 ▼] [配置] [删除] [上移][下移] │ │
|
||||||
|
│ │ 4. age [年龄 ▼] [配置] [删除] [上移][下移] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [+ 添加字段] │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 生成选项 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 数量: [100] 格式: [JSON ▼] │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [保存修改] [取消] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**编辑逻辑**:
|
||||||
|
|
||||||
|
1. 加载原规则配置到编辑器
|
||||||
|
2. 用户修改配置
|
||||||
|
3. 点击保存时更新规则
|
||||||
|
4. 更新 metadata.updatedAt
|
||||||
|
5. 保存到 localStorage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 规则删除
|
||||||
|
|
||||||
|
**删除流程**:
|
||||||
|
|
||||||
|
1. 点击删除按钮
|
||||||
|
2. 弹出确认对话框
|
||||||
|
3. 确认后删除规则
|
||||||
|
4. 刷新规则列表
|
||||||
|
|
||||||
|
**确认对话框**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 确认删除 │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 确定要删除规则 "电商用户数据" 吗? │
|
||||||
|
│ │
|
||||||
|
│ 此操作不可撤销。 │
|
||||||
|
│ │
|
||||||
|
│ [取消] [删除] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 规则加载
|
||||||
|
|
||||||
|
**加载流程**:
|
||||||
|
|
||||||
|
1. 点击"加载"按钮
|
||||||
|
2. 将规则配置应用到当前编辑器
|
||||||
|
3. 更新预览数据
|
||||||
|
4. 记录使用时间和次数
|
||||||
|
|
||||||
|
**实现方式**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
loadRule(ruleId: string): void {
|
||||||
|
const rule = this.storage.getById(ruleId);
|
||||||
|
|
||||||
|
// 应用规则配置
|
||||||
|
this.setFields(rule.fields);
|
||||||
|
this.setOptions(rule.options);
|
||||||
|
|
||||||
|
// 记录使用
|
||||||
|
this.storage.recordUse(ruleId);
|
||||||
|
|
||||||
|
// 更新预览
|
||||||
|
this.updatePreview();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 规则复制
|
||||||
|
|
||||||
|
**复制逻辑**:
|
||||||
|
|
||||||
|
1. 点击"复制"按钮
|
||||||
|
2. 创建规则的副本
|
||||||
|
3. 名称添加"(副本)"后缀
|
||||||
|
4. 生成新的 ID
|
||||||
|
5. 保存为新规则
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 规则导出
|
||||||
|
|
||||||
|
**导出界面**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 导出规则 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 选择要导出的规则: │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ☑ 电商用户数据 - 测试用 (4个字段, 100条) │ │
|
||||||
|
│ │ ☑ 电商订单数据 - 测试用 (6个字段, 500条) │ │
|
||||||
|
│ │ ☐ 用户登录数据 - 测试用 (3个字段, 50条) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [全选] [全不选] │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 导出格式: │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ○ 导出为单个 JSON 文件 (所有规则合并) │ │
|
||||||
|
│ │ ● 导出为多个 JSON 文件 (每个规则一个文件) │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [导出] [取消] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**导出格式**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"exportedAt": "2024-01-20T10:15:45.000Z",
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"id": "rule_123456",
|
||||||
|
"name": "电商用户数据 - 测试用",
|
||||||
|
"description": "用于测试用户注册功能",
|
||||||
|
"fields": [...],
|
||||||
|
"options": {...},
|
||||||
|
"metadata": {...}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 规则导入
|
||||||
|
|
||||||
|
**导入界面**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 导入规则 │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 选择导入方式: │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ○ 从文件导入 │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ 拖拽文件到这里,或 [点击选择文件] │ │ │
|
||||||
|
│ │ │ 支持格式: .json │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ○ 从剪贴板粘贴 │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ 粘贴 JSON 规则内容... │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 预览导入内容: │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 格式验证通过 │ │
|
||||||
|
│ │ 规则数量: 2 个 │ │
|
||||||
|
│ │ • 电商用户数据 (4个字段) │ │
|
||||||
|
│ │ • 电商订单数据 (6个字段) │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [导入] [取消] │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**导入逻辑**:
|
||||||
|
|
||||||
|
1. 解析 JSON 文件
|
||||||
|
2. 验证格式是否正确
|
||||||
|
3. 预览导入内容
|
||||||
|
4. 确认后逐条导入
|
||||||
|
5. 生成新 ID 避免冲突
|
||||||
|
6. 处理导入结果(成功/失败数量)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 存储机制
|
||||||
|
|
||||||
|
### 本地存储
|
||||||
|
|
||||||
|
使用 localStorage 存储规则数据:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class RuleStorage {
|
||||||
|
private readonly STORAGE_KEY = 'testDataGenerator_rules';
|
||||||
|
|
||||||
|
// 获取所有规则
|
||||||
|
getAll(): DataRule[] {
|
||||||
|
const data = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
return data ? JSON.parse(data) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存规则
|
||||||
|
save(rule: DataRule): { success: boolean; message?: string } {
|
||||||
|
const rules = this.getAll();
|
||||||
|
|
||||||
|
// 规则数量限制:最多 20 条
|
||||||
|
const MAX_RULES = 20;
|
||||||
|
if (rules.length >= MAX_RULES) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `已达到最大规则数量(${MAX_RULES}条),请删除一些规则后再保存`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rules.push(rule);
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules));
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新规则
|
||||||
|
update(id: string, updates: Partial<DataRule>): void {
|
||||||
|
const rules = this.getAll();
|
||||||
|
const index = rules.findIndex((r) => r.id === id);
|
||||||
|
if (index !== -1) {
|
||||||
|
rules[index] = { ...rules[index], ...updates };
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除规则
|
||||||
|
delete(id: string): void {
|
||||||
|
const rules = this.getAll();
|
||||||
|
const filtered = rules.filter((r) => r.id !== id);
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录使用
|
||||||
|
recordUse(id: string): void {
|
||||||
|
const rules = this.getAll();
|
||||||
|
const rule = rules.find((r) => r.id === id);
|
||||||
|
if (rule) {
|
||||||
|
rule.metadata.lastUsedAt = Date.now();
|
||||||
|
rule.metadata.useCount++;
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 存储限制
|
||||||
|
|
||||||
|
- **规则数量限制**: 最多 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,51 +0,0 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
|
||||||
import { browser } from 'wxt/browser';
|
|
||||||
|
|
||||||
export default defineBackground(() => {
|
|
||||||
// 监听扩展安装或更新事件
|
|
||||||
browser.runtime.onInstalled.addListener(async ({ reason }) => {
|
|
||||||
if (reason === 'install') {
|
|
||||||
console.log('Extension installed for the first time');
|
|
||||||
} else if (reason === 'update') {
|
|
||||||
console.log('Extension updated to a new version');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有标签页
|
|
||||||
const tabs = await browser.tabs.query({});
|
|
||||||
|
|
||||||
// 过滤不合法或受限制的 URL
|
|
||||||
const targetTabs = tabs.filter((tab) => {
|
|
||||||
if (!tab.id || !tab.url) return false;
|
|
||||||
const restrictedProtocols = [
|
|
||||||
'chrome:',
|
|
||||||
'chrome-extension:',
|
|
||||||
'about:',
|
|
||||||
'edge:',
|
|
||||||
'view-source:',
|
|
||||||
];
|
|
||||||
return !restrictedProtocols.some((protocol) => tab.url!.startsWith(protocol));
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
targetTabs.map((tab) =>
|
|
||||||
browser.scripting
|
|
||||||
.executeScript({
|
|
||||||
target: { tabId: tab.id! },
|
|
||||||
files: ['/content-scripts/content.js'],
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.warn(`Failed to inject script into tab ${tab.id}:`, err.message);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
||||||
console.log(
|
|
||||||
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
chrome.tabs.onUpdated.addListener((tabId) => {
|
|
||||||
console.log('加载完成的 Tab ID:', tabId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
|
||||||
|
|
||||||
export default defineContentScript({
|
|
||||||
matches: ['<all_urls>'],
|
|
||||||
runAt: 'document_start',
|
|
||||||
main() {
|
|
||||||
// Content script placeholder
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Document</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
Hello Options Page
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/* 隐藏页面滚动条 */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 隐藏body滚动条但允许滚动 */
|
|
||||||
body {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* 统一圆角变量 */
|
|
||||||
--radius: 8px;
|
|
||||||
/* 统一背景颜色变量 */
|
|
||||||
--bg-color: #fafafa;
|
|
||||||
/* 统一文字颜色变量 */
|
|
||||||
--text-color: #333333;
|
|
||||||
/* 统一按钮颜色变量 */
|
|
||||||
--btn-bg: #e0e0e0;
|
|
||||||
/* 统一按钮文字颜色变量 */
|
|
||||||
--btn-text: #333333;
|
|
||||||
--border: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
width: 400px;
|
|
||||||
min-height: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航容器 */
|
|
||||||
.nav-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航按钮 */
|
|
||||||
.nav-button {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--btn-bg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--btn-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button:hover {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-button.active {
|
|
||||||
background: var(--btn-bg);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import TimestampPage from './pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
|
|
||||||
const PAGE_CONFIG = {
|
|
||||||
timestamp: { label: '时间戳', defaultVisible: true },
|
|
||||||
storageCleaner: { label: '存储清理', defaultVisible: true },
|
|
||||||
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>(['timestamp', 'storageCleaner']);
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadInitialData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded) {
|
|
||||||
storageUtil.set('app/currentRoute', currentPage);
|
|
||||||
}
|
|
||||||
}, [currentPage, isLoaded]);
|
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
|
||||||
try {
|
|
||||||
const [savedRoute, savedVisiblePages] = await Promise.all([
|
|
||||||
storageUtil.get('app/currentRoute', 'timestamp'),
|
|
||||||
storageUtil.get('app/visiblePages', ['timestamp', 'storageCleaner'] as PageType[]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (savedRoute) {
|
|
||||||
setCurrentPage(savedRoute);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedVisiblePages) {
|
|
||||||
setVisiblePages(savedVisiblePages);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load initial data:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (page: PageType) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
const NavButton = ({ pageKey }: { pageKey: PageType }) => {
|
|
||||||
const config = PAGE_CONFIG[pageKey];
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={pageKey}>
|
|
||||||
<button
|
|
||||||
className={currentPage === pageKey ? 'nav-button active' : 'nav-button'}
|
|
||||||
onClick={() => handlePageChange(pageKey)}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return <div className="app">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<Box className="nav-container">
|
|
||||||
{(Object.keys(PAGE_CONFIG) as PageType[])
|
|
||||||
.filter((key) => visiblePages.includes(key))
|
|
||||||
.map((key) => <NavButton key={key} pageKey={key} />)}
|
|
||||||
</Box>
|
|
||||||
{currentPage === 'timestamp' && <TimestampPage />}
|
|
||||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -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>Default Popup 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,328 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
FormControlLabel,
|
|
||||||
Alert,
|
|
||||||
Snackbar,
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
|
||||||
import {
|
|
||||||
getCurrentTab,
|
|
||||||
isRestrictedUrl,
|
|
||||||
clearStorage,
|
|
||||||
formatCleaningResult,
|
|
||||||
} from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
|
||||||
localStorage: true,
|
|
||||||
sessionStorage: true,
|
|
||||||
indexedDB: true,
|
|
||||||
cookies: true,
|
|
||||||
cacheStorage: true,
|
|
||||||
serviceWorkers: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
|
||||||
autoRefresh: true,
|
|
||||||
selectedTypes: DEFAULT_OPTIONS,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
|
||||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
|
||||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
|
||||||
open: false,
|
|
||||||
message: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load tab info and user preferences
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInfo = async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.url) {
|
|
||||||
setError('无法获取当前标签页');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRestrictedUrl(tab.url)) {
|
|
||||||
setError('存储清理功能不支持此页面');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDomain(new URL(tab.url).hostname);
|
|
||||||
|
|
||||||
// Load user preferences
|
|
||||||
const prefs = await storageUtil.get(
|
|
||||||
'storageCleaner/preferences',
|
|
||||||
DEFAULT_PREFERENCES,
|
|
||||||
);
|
|
||||||
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
|
||||||
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInfo();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
|
||||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleClean = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
|
|
||||||
if (!tab || !tab.id || !tab.url) {
|
|
||||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
|
||||||
setResult(cleaningResult);
|
|
||||||
|
|
||||||
// Save user preferences
|
|
||||||
await storageUtil.set('storageCleaner/preferences', {
|
|
||||||
autoRefresh,
|
|
||||||
selectedTypes: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto refresh if enabled
|
|
||||||
if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id!);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setShowConfirm(false);
|
|
||||||
}
|
|
||||||
}, [options, autoRefresh]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
const tab = await getCurrentTab();
|
|
||||||
if (tab?.id !== undefined) {
|
|
||||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
|
||||||
setTimeout(() => {
|
|
||||||
chrome.tabs.reload(tab.id!);
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
<Alert severity="error" icon={<WarningIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
|
||||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
|
||||||
存储清理
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
当前页面: {domain || '加载中...'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Storage Type Options */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
|
||||||
选择要清理的存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.localStorage}
|
|
||||||
onChange={() => handleOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="localStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
onChange={() => handleOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="sessionStorage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.indexedDB}
|
|
||||||
onChange={() => handleOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="IndexedDB"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cookies}
|
|
||||||
onChange={() => handleOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cookies"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
onChange={() => handleOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Cache Storage"
|
|
||||||
/>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
onChange={() => handleOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Service Workers"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Auto Refresh Option */}
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="清理完成后自动刷新页面"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '清理中...' : '清理'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Result Display */}
|
|
||||||
{result && (
|
|
||||||
<Box sx={{ mb: 2 }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
{!autoRefresh && result.success && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={handleRefresh}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
刷新页面
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Confirmation Dialog */}
|
|
||||||
{showConfirm && (
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255,255,255, 0.95)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 2,
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h6">确认清理</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
|
||||||
将清理以下存储类型:
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ mb: 1 }}>
|
|
||||||
{options.localStorage && (
|
|
||||||
<Typography variant="body2">- localStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.sessionStorage && (
|
|
||||||
<Typography variant="body2">- sessionStorage</Typography>
|
|
||||||
)}
|
|
||||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
|
||||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
|
||||||
{options.cacheStorage && (
|
|
||||||
<Typography variant="body2">- Cache Storage</Typography>
|
|
||||||
)}
|
|
||||||
{options.serviceWorkers && (
|
|
||||||
<Typography variant="body2">- Service Workers</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ textAlign: 'center', mb: 1 }}
|
|
||||||
>
|
|
||||||
此操作不可撤销。
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button variant="contained" color="error" onClick={handleClean}>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Snackbar */}
|
|
||||||
<Snackbar
|
|
||||||
open={snackbar.open}
|
|
||||||
autoHideDuration={3000}
|
|
||||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
|
||||||
>
|
|
||||||
<Alert severity="info" variant="filled">
|
|
||||||
{snackbar.message}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
TextField,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
Snackbar,
|
|
||||||
Alert,
|
|
||||||
InputAdornment,
|
|
||||||
alpha,
|
|
||||||
Tooltip,
|
|
||||||
Theme,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
|
|
||||||
// ================= 常量配置 =================
|
|
||||||
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
|
||||||
const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
|
||||||
|
|
||||||
type UnitType = 'ms' | 's';
|
|
||||||
type ZoneType = (typeof ZONES)[number];
|
|
||||||
|
|
||||||
const INPUT_STYLE = {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'& fieldset': { border: 'none' },
|
|
||||||
'&:hover': { bgcolor: 'grey.100' },
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
|
|
||||||
},
|
|
||||||
'&.Mui-error': {
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// ================= 子组件:实时时钟 =================
|
|
||||||
interface LiveClockProps {
|
|
||||||
unit: UnitType;
|
|
||||||
onCopy: (val: string) => void;
|
|
||||||
onUseNow: (val: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveClock = React.memo(({
|
|
||||||
unit,
|
|
||||||
onCopy,
|
|
||||||
onUseNow
|
|
||||||
}: LiveClockProps) => {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayVal = useMemo(() =>
|
|
||||||
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
[now, unit]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
|
|
||||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
|
||||||
<Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
|
|
||||||
{displayVal}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
|
|
||||||
{unit}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="填充到下方">
|
|
||||||
<IconButton
|
|
||||||
aria-label="use current time"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onUseNow(now)}
|
|
||||||
sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }}
|
|
||||||
>
|
|
||||||
<AccessTimeIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="复制当前时间戳">
|
|
||||||
<IconButton
|
|
||||||
aria-label="copy current timestamp"
|
|
||||||
size="small"
|
|
||||||
onClick={() => onCopy(displayVal)}
|
|
||||||
sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
|
|
||||||
>
|
|
||||||
<ContentCopyIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
// ================= 子组件:多维度结果展示 =================
|
|
||||||
interface ResultViewProps {
|
|
||||||
result: string;
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
onCopy: (val: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultView = React.memo(({
|
|
||||||
result,
|
|
||||||
mode,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
onCopy
|
|
||||||
}: ResultViewProps) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
|
||||||
onCopy(result);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
}, [onCopy, result]);
|
|
||||||
|
|
||||||
const extraInfo = useMemo(() => {
|
|
||||||
if (!result) return null;
|
|
||||||
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
relative: d.fromNow(),
|
|
||||||
iso: d.toISOString(),
|
|
||||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
||||||
};
|
|
||||||
}, [result, mode, zone, unit]);
|
|
||||||
|
|
||||||
if (!result) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
|
|
||||||
animation: 'fadeIn 0.3s ease-out',
|
|
||||||
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
|
|
||||||
}}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.disabled', mb: 1, display: 'block', ml: 1, fontWeight: 500 }}>
|
|
||||||
转换结果
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
value={result}
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton
|
|
||||||
aria-label="copy result"
|
|
||||||
size="small"
|
|
||||||
onClick={handleCopy}
|
|
||||||
sx={{
|
|
||||||
color: copied ? 'success.main' : 'primary.main',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
||||||
transform: copied ? 'scale(1.2)' : 'scale(1)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
...INPUT_STYLE,
|
|
||||||
mb: 2,
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
...INPUT_STYLE['& .MuiOutlinedInput-root'],
|
|
||||||
bgcolor: alpha('#2563eb', 0.03),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 辅助信息预览 */}
|
|
||||||
<Stack spacing={1} sx={{ px: 1 }}>
|
|
||||||
{[
|
|
||||||
{ label: '相对时间', value: extraInfo?.relative },
|
|
||||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
|
||||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
|
||||||
].map((item) => (
|
|
||||||
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
onClick={() => { if (item.value) onCopy(item.value); }}
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'text.primary',
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { color: 'primary.main', textDecoration: 'underline' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
|
||||||
|
|
||||||
// ================= 主页面组件 =================
|
|
||||||
export default function TimestampPage() {
|
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
|
||||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
|
||||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
|
||||||
const [unit, setUnit] = useState<UnitType>('ms');
|
|
||||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
|
||||||
const [result, setResult] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
|
|
||||||
|
|
||||||
const copy = useCallback(async (text: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
setSnack({ open: true, msg: '已复制' });
|
|
||||||
} catch {
|
|
||||||
setSnack({ open: true, msg: '复制失败' });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const convert = useCallback(() => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
const rawInput = tsInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const num = Number(rawInput);
|
|
||||||
if (isNaN(num)) { setError('无效数字'); return; }
|
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
|
||||||
if (!d.isValid()) { setError('无效时间戳'); return; }
|
|
||||||
setError('');
|
|
||||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
|
||||||
} else {
|
|
||||||
const rawInput = dtInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
|
||||||
if (!d.isValid()) { setError('格式错误'); return; }
|
|
||||||
setError('');
|
|
||||||
const ms = d.valueOf();
|
|
||||||
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
|
||||||
}
|
|
||||||
}, [mode, tsInput, dtInput, unit, zone]);
|
|
||||||
|
|
||||||
// 智能实时转换 (Debounce Effect)
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
convert();
|
|
||||||
}, 400);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [convert]);
|
|
||||||
|
|
||||||
const handleUseNow = useCallback((now: number) => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
|
||||||
} else {
|
|
||||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
|
||||||
}
|
|
||||||
}, [mode, unit, zone]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2.5,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 1. 实时时钟 */}
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
|
|
||||||
<Tooltip title="切换单位">
|
|
||||||
<IconButton
|
|
||||||
aria-label="switch unit"
|
|
||||||
size="small"
|
|
||||||
onClick={() => { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', right: 80, top: 4, color: 'grey.400',
|
|
||||||
transition: 'transform 0.3s ease',
|
|
||||||
'&:hover': { transform: 'rotate(180deg)', color: 'primary.main' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SwapHorizIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 2. 模式切换 */}
|
|
||||||
<Box sx={{ position: 'relative', display: 'flex', p: 0.5, bgcolor: 'grey.100', borderRadius: 3.5, mb: 3, overflow: 'hidden' }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute', height: 'calc(100% - 8px)', width: 'calc(50% - 4px)',
|
|
||||||
bgcolor: '#fff', borderRadius: 3, boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
|
||||||
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
|
||||||
top: 4, left: 4,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
|
||||||
<Button
|
|
||||||
key={m} fullWidth disableRipple
|
|
||||||
onClick={() => { setMode(m); setError(''); setResult(''); }}
|
|
||||||
sx={{
|
|
||||||
position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
|
|
||||||
fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
|
|
||||||
color: mode === m ? 'text.primary' : 'text.disabled',
|
|
||||||
'&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 3. 输入与设置 */}
|
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
|
||||||
<TextField
|
|
||||||
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
|
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(val);
|
|
||||||
} else {
|
|
||||||
setDtInput(val);
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error}
|
|
||||||
fullWidth
|
|
||||||
sx={INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
<Select
|
|
||||||
fullWidth value={unit}
|
|
||||||
onChange={(e) => { setUnit(e.target.value as UnitType); }}
|
|
||||||
sx={{ ...INPUT_STYLE, flex: 1 }}
|
|
||||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
|
||||||
>
|
|
||||||
<MenuItem value="ms">毫秒 (ms)</MenuItem>
|
|
||||||
<MenuItem value="s">秒 (s)</MenuItem>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
fullWidth value={zone}
|
|
||||||
onChange={(e) => { setZone(e.target.value as ZoneType); }}
|
|
||||||
sx={{ ...INPUT_STYLE, flex: 1.5 }}
|
|
||||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
|
||||||
>
|
|
||||||
{ZONES.map((z) => (
|
|
||||||
<MenuItem key={z} value={z}>{z}</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* 4. 转换操作 (作为手动确认) */}
|
|
||||||
<Button
|
|
||||||
fullWidth variant="contained" disableElevation disableRipple
|
|
||||||
onClick={convert}
|
|
||||||
sx={{
|
|
||||||
py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
|
|
||||||
bgcolor: 'primary.main', transition: 'all 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
|
|
||||||
'&:active': { transform: 'translateY(0)' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
立即转换
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 5. 结果展示 */}
|
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Snackbar
|
|
||||||
open={snack.open} autoHideDuration={1500}
|
|
||||||
onClose={() => { setSnack((s) => ({ ...s, open: false })); }}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
>
|
|
||||||
<Alert severity="success" variant="filled" icon={false} sx={{ borderRadius: 2.5, bgcolor: 'grey.900' }}>
|
|
||||||
{snack.msg}
|
|
||||||
</Alert>
|
|
||||||
</Snackbar>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
|
||||||
monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-size: inherit; /* 先全部重置为继承大小 */
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,69 @@
|
|||||||
import js from '@eslint/js';
|
import js from '@eslint/js';
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import * as reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import * as reactPlugin from 'eslint-plugin-react';
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] },
|
{
|
||||||
|
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||||
|
},
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
{
|
{
|
||||||
files: [
|
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
|
||||||
'hooks/**/*.{ts,tsx}',
|
rules: {
|
||||||
'entrypoints/**/*.{ts,tsx}',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'pages/**/*.{ts,tsx}',
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'utils/**/*.{ts,tsx}',
|
'warn',
|
||||||
'components/**/*.{ts,tsx}',
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
'services/**/*.{ts,tsx}',
|
],
|
||||||
],
|
},
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
},
|
},
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ['./tsconfig.json'],
|
projectService: true,
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: {
|
plugins: {
|
||||||
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了
|
react: reactPlugin,
|
||||||
'react-hooks': reactHooks as any,
|
'react-hooks': reactHooks,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
...reactPlugin.configs.recommended.rules,
|
||||||
|
...reactPlugin.configs['jsx-runtime'].rules,
|
||||||
|
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
|
/* global process */
|
||||||
|
|
||||||
|
const isCI = process.env.CI === 'true';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// 对于代码文件:
|
'*.{ts,tsx,js,jsx,mjs}': [
|
||||||
'*.{ts,tsx,js,jsx}': [
|
'eslint --fix --no-warn-ignored',
|
||||||
// 1. ESLint: 依然检查具体文件,拦截未使用变量
|
...(isCI ? ['eslint --max-warnings=0'] : []),
|
||||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
'prettier --write',
|
||||||
|
|
||||||
// 2. TypeScript: 使用函数形式
|
|
||||||
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好”
|
|
||||||
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
|
|
||||||
() => 'tsc --noEmit --skipLibCheck',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// 对于其他文件:
|
|
||||||
'*.{json,css,scss,md}': ['prettier --write'],
|
'*.{json,css,scss,md}': ['prettier --write'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "wxt-react-starter",
|
"name": "testing-tools",
|
||||||
"description": "manifest.json description",
|
"description": "A browser extension providing useful testing tools including timestamp conversion and storage management",
|
||||||
"private": true,
|
"private": false,
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wxt",
|
"dev": "wxt",
|
||||||
@@ -11,40 +12,65 @@
|
|||||||
"build:firefox": "wxt build -b firefox",
|
"build:firefox": "wxt build -b firefox",
|
||||||
"zip": "wxt zip",
|
"zip": "wxt zip",
|
||||||
"zip:firefox": "wxt zip -b firefox",
|
"zip:firefox": "wxt zip -b firefox",
|
||||||
"compile": "tsc --noEmit",
|
|
||||||
"postinstall": "wxt prepare",
|
"postinstall": "wxt prepare",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"lint": "eslint . --max-warnings=0"
|
"lint": "eslint . --max-warnings=0",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@mui/material": "^7.3.8",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@webext-core/messaging": "^2.3.0",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"dayjs": "^1.11.19",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"react": "^19.2.3",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"react-dom": "^19.2.3"
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/chrome": "^0.1.36",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@types/react": "^19.2.7",
|
"@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/react-dom": "^19.2.3",
|
||||||
"@types/webextension-polyfill": "^0.12.4",
|
"@types/webextension-polyfill": "^0.12.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.59.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@wxt-dev/module-react": "^1.1.5",
|
"@wxt-dev/module-react": "^1.2.2",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.2.0",
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.2.7",
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.8.1",
|
"lint-staged": "^17.0.5",
|
||||||
"terser": "^5.46.0",
|
"postcss": "^8.5.15",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"terser": "^5.47.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.59.4",
|
||||||
"wxt": "^0.20.6"
|
"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],
|
||||||
|
};
|
||||||
@@ -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: 15 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 932 B |
|
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -1,15 +0,0 @@
|
|||||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g clip-path="url(#clip0_305_516)">
|
|
||||||
<g clip-path="url(#clip1_305_516)">
|
|
||||||
<path d="M49.0229 69.1875C54.1272 69.1875 58.265 65.0497 58.265 59.9454V50.7033H59.9454C65.0497 50.7033 69.1875 46.5655 69.1875 41.4612C69.1875 36.357 65.0497 32.2191 59.9454 32.2191H58.265V22.9771C58.265 17.8728 54.1272 13.735 49.0229 13.735H39.7809V12.0546C39.7809 6.95032 35.643 2.8125 30.5388 2.8125C25.4345 2.8125 21.2967 6.95032 21.2967 12.0546V13.735H12.0546C6.95032 13.735 2.8125 17.8728 2.8125 22.9771V32.2191H4.49288C9.59714 32.2191 13.735 36.357 13.735 41.4612C13.735 46.5655 9.59714 50.7033 4.49288 50.7033H2.8125V69.1875H21.2967V67.5071C21.2967 62.4029 25.4345 58.265 30.5388 58.265C35.643 58.265 39.7809 62.4029 39.7809 67.5071V69.1875H49.0229Z" stroke="#67D55E" stroke-width="5.625"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_305_516">
|
|
||||||
<rect width="72" height="72" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="clip1_305_516">
|
|
||||||
<rect width="72" height="72" fill="white"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
# Spec 目录
|
||||||
|
|
||||||
|
功能规格、修复方案与验收标准文档。
|
||||||
|
|
||||||
|
| 文档 | 状态 | 说明 |
|
||||||
|
| -------------------------------------------------------------------------------- | ----------------- | ---------------------------------------------------- |
|
||||||
|
| [storage-cleaner/indexeddb-fix-plan.md](./storage-cleaner/indexeddb-fix-plan.md) | ✅ Phase 3 已完成 | Storage Cleaner IndexedDB 清理逻辑修复方案与验收标准 |
|
||||||
@@ -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,24 @@
|
|||||||
|
# src/
|
||||||
|
|
||||||
|
源码目录,存放全局样式定义。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ----------- | ----------------- |
|
||||||
|
| `index.css` | 全局 CSS 入口文件 |
|
||||||
|
|
||||||
|
## index.css
|
||||||
|
|
||||||
|
全局样式入口,包含:
|
||||||
|
|
||||||
|
- **Tailwind 指令**:`@tailwind base/components/utilities`
|
||||||
|
- **shadcn/ui CSS 变量**:定义 `--background`、`--primary`、`--destructive`、`--card`、`--muted`、`--accent`、`--border`、`--ring` 等语义化颜色变量
|
||||||
|
- **主题色值**:`:root`(亮色)和 `.dark`(暗色)两套完整的颜色定义
|
||||||
|
- **圆角变量**:`--radius` 定义全局圆角大小
|
||||||
|
|
||||||
|
## 修改注意事项
|
||||||
|
|
||||||
|
- 修改 CSS 变量会影响所有使用 shadcn/ui 语义化 token 的组件
|
||||||
|
- 新增颜色变量需同时在 `:root` 和 `.dark` 中定义
|
||||||
|
- 避免在组件中硬编码颜色值,应使用 CSS 变量或 Tailwind 的语义化类名
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { Button, type ButtonProps } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
|
text,
|
||||||
|
tooltip,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'icon',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
toast.error('无内容可复制');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await copyTextToClipboard(text);
|
||||||
|
if (success) {
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
setCopied(true);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
toast.error('复制失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={tooltip ?? '复制'}
|
||||||
|
aria-label={tooltip ?? '复制'}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(className, copied && 'text-emerald-500')}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-[1.2em] w-[1.2em]" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// unmock the globally-mocked component so we test the real implementation
|
||||||
|
vi.unmock('@/components/CopyButton');
|
||||||
|
|
||||||
|
vi.mock('@/utils/clipboard', () => ({
|
||||||
|
copyTextToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const mockedCopy = vi.mocked(copyTextToClipboard);
|
||||||
|
const mockedToast = vi.mocked(toast);
|
||||||
|
|
||||||
|
describe('CopyButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="hello world" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('hello world');
|
||||||
|
expect(mockedToast.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功后图标切换为 Check,1.5 秒后恢复', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="test" />);
|
||||||
|
|
||||||
|
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button').className).toContain('text-emerald');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1.5 秒后样式恢复
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button').className).not.toContain('text-emerald');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制空文本时弹出 error toast', async () => {
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).not.toHaveBeenCalled();
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败时弹出 error toast', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(false);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="something" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('something');
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 新增测试 ====================
|
||||||
|
|
||||||
|
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
|
||||||
|
render(<CopyButton text="initial" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).not.toContain('text-emerald');
|
||||||
|
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
|
||||||
|
expect(button).toHaveAttribute('aria-label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
|
||||||
|
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('title', '自定义提示');
|
||||||
|
expect(button).toHaveAttribute('aria-label', '自定义提示');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('className 被正确透传到按钮', () => {
|
||||||
|
render(<CopyButton text="class-test" className="my-custom-class" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).toContain('my-custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击事件阻止冒泡', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
const parentClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<CopyButton text="stop-propagation" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalled();
|
||||||
|
expect(parentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
const { unmount } = render(<CopyButton text="unmount-test" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
// 在 1.5 秒超时到期前卸载组件
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
|
||||||
|
// 前进剩余时间,确认没有异常
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快速连续点击不会创建多个重叠定时器', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="rapid-click" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
// 快速点击 3 次
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
|
||||||
|
expect(mockedCopy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
|
||||||
|
// advance 1.5 秒后,copied 状态应恢复为 false
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(button.className).not.toContain('text-emerald');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他 button props 通过 ...props 透传', () => {
|
||||||
|
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('data-testid', 'copy-btn');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute('id', 'copy-button-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface EmptyPlaceholderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
messageClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTAINER_CLASSES =
|
||||||
|
'rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center select-none p-8 min-h-[120px]';
|
||||||
|
|
||||||
|
const MESSAGE_CLASSES =
|
||||||
|
'text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed';
|
||||||
|
|
||||||
|
export default function EmptyPlaceholder({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
messageClassName,
|
||||||
|
...props
|
||||||
|
}: EmptyPlaceholderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(CONTAINER_CLASSES, className)} {...props}>
|
||||||
|
{typeof children === 'string' || typeof children === 'number' ? (
|
||||||
|
<p className={cn(MESSAGE_CLASSES, messageClassName)}>{children}</p>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { ErrorFallback } from '@/components/ErrorFallback';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
if (this.state.hasError && prevProps.children !== this.props.children) {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<ErrorFallback
|
||||||
|
variant="app"
|
||||||
|
title="糟糕,出了点问题"
|
||||||
|
description="应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。"
|
||||||
|
error={this.state.error}
|
||||||
|
actionLabel="刷新应用"
|
||||||
|
onAction={this.handleReset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ErrorFallbackProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
error: Error | null;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction: () => void;
|
||||||
|
variant?: 'app' | 'page';
|
||||||
|
showStack?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorFallback({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
variant = 'page',
|
||||||
|
showStack = false,
|
||||||
|
className,
|
||||||
|
}: ErrorFallbackProps) {
|
||||||
|
const isApp = variant === 'app';
|
||||||
|
const errorText = error ? (showStack ? error.stack || error.toString() : error.toString()) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center',
|
||||||
|
isApp
|
||||||
|
? 'mt-16 mx-auto max-w-md'
|
||||||
|
: 'flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm',
|
||||||
|
!isApp && 'max-w-md w-full',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4',
|
||||||
|
isApp ? 'h-16 w-16' : 'h-12 w-12',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AlertCircle className={isApp ? 'h-8 w-8' : 'h-6 w-6'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isApp ? (
|
||||||
|
<h2 className="text-xl font-extrabold text-destructive mb-2">{title}</h2>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-base font-semibold text-foreground mb-1.5">{title}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className={cn('text-muted-foreground', isApp ? 'text-sm mb-6' : 'text-xs mb-5')}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{errorText && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left border border-border/40',
|
||||||
|
isApp ? 'mb-6 p-4 max-h-[200px] overflow-auto' : 'mb-5 p-3 max-h-40 overflow-y-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'font-mono whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700',
|
||||||
|
isApp ? 'text-xs' : 'text-[11px] leading-relaxed',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{errorText}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size={isApp ? 'default' : 'sm'}
|
||||||
|
onClick={onAction}
|
||||||
|
className={cn(isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm')}
|
||||||
|
>
|
||||||
|
<RefreshCw className={isApp ? 'mr-2 h-4 w-4' : 'mr-1.5 h-3.5 w-3.5'} />
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { ErrorFallback } from '@/components/ErrorFallback';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
resetKey?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PageErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error in page:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<ErrorFallback
|
||||||
|
variant="page"
|
||||||
|
title="该功能运行异常"
|
||||||
|
description="该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。"
|
||||||
|
error={this.state.error}
|
||||||
|
actionLabel="重新尝试"
|
||||||
|
onAction={this.handleRetry}
|
||||||
|
showStack
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PageErrorBoundary };
|
||||||
|
export default PageErrorBoundary;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* PageSkeleton 组件 - 页面加载骨架屏
|
||||||
|
*
|
||||||
|
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
|
||||||
|
* 避免白屏闪烁,减少布局偏移
|
||||||
|
*/
|
||||||
|
interface PageSkeletonProps {
|
||||||
|
/** 骨架屏类型 */
|
||||||
|
variant?: 'dashboard' | 'tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘卡片骨架屏
|
||||||
|
*/
|
||||||
|
function DashboardCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border p-5 h-[100px]">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
|
||||||
|
<div>
|
||||||
|
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具页面骨架屏
|
||||||
|
*/
|
||||||
|
function ToolPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
|
||||||
|
|
||||||
|
{/* 控制栏 */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果区域 */}
|
||||||
|
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面加载骨架屏
|
||||||
|
*
|
||||||
|
* @param props - PageSkeletonProps
|
||||||
|
* @returns 骨架屏 JSX 元素
|
||||||
|
*/
|
||||||
|
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
|
||||||
|
if (variant === 'tool') {
|
||||||
|
return <ToolPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<DashboardCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PageSkeleton.displayName = 'PageSkeleton';
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# components/
|
||||||
|
|
||||||
|
通用业务组件目录,存放跨页面复用的 UI 组件,与具体工具页面解耦。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||||
|
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||||
|
| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 |
|
||||||
|
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||||
|
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 |
|
||||||
|
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
|
||||||
|
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
|
||||||
|
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
|
||||||
|
| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 |
|
||||||
|
| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 |
|
||||||
|
| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 |
|
||||||
|
| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback |
|
||||||
|
|
||||||
|
## 使用约定
|
||||||
|
|
||||||
|
- 优先使用 `components/ui/` 下的 shadcn/ui 基础组件
|
||||||
|
- 组件使用 `cn()` 合并 Tailwind 类名,支持 `className` 透传
|
||||||
|
- 需要 memo 优化的组件使用 `React.memo` + `displayName`
|
||||||
|
- 需要 ref 转发的组件使用 `React.forwardRef` + `displayName`
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const entryPointType = getEntryPointType();
|
||||||
|
|
||||||
|
export default function RouterContainer() {
|
||||||
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
|
||||||
|
const animationClass =
|
||||||
|
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||||
|
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={currentPage}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||||
|
'scrollbar-gutter-stable motion-reduce:transition-none',
|
||||||
|
animationClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||||
|
>
|
||||||
|
<PageErrorBoundary resetKey={currentPage}>
|
||||||
|
{MatchedComponent ? (
|
||||||
|
<MatchedComponent />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">页面未找到</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||||
|
{`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SwitchOption<T extends string | number = string> {
|
||||||
|
value: T;
|
||||||
|
label: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
value: T;
|
||||||
|
options: SwitchOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
buttonClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASSES = {
|
||||||
|
small: 'text-xs h-8 px-2 py-1 rounded-md',
|
||||||
|
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
|
||||||
|
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SELECTED_CLASSES =
|
||||||
|
'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150';
|
||||||
|
const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80';
|
||||||
|
|
||||||
|
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
size = 'medium',
|
||||||
|
className,
|
||||||
|
buttonClassName,
|
||||||
|
...props
|
||||||
|
}: SwitchButtonGroupProps<T>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 transition-all',
|
||||||
|
SIZE_CLASSES[size],
|
||||||
|
value === option.value ? SELECTED_CLASSES : UNSELECTED_CLASSES,
|
||||||
|
buttonClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
export type ValidateRule = {
|
||||||
|
validator: (value: string) => boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolbarAction = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
type?: 'primary' | 'default' | 'danger';
|
||||||
|
disabled?: boolean | ((value: string) => boolean);
|
||||||
|
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TextInputAreaProps extends Omit<
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
|
||||||
|
/** 值变化回调,返回最新的字符串内容 */
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
showCount?: boolean;
|
||||||
|
showClear?: boolean;
|
||||||
|
allowCopy?: boolean;
|
||||||
|
rules?: ValidateRule[];
|
||||||
|
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||||
|
actions?: ToolbarAction[];
|
||||||
|
topExtra?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
externalError?: string;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
action,
|
||||||
|
value,
|
||||||
|
globalDisabled,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
action: ToolbarAction;
|
||||||
|
value: string;
|
||||||
|
globalDisabled: boolean;
|
||||||
|
onAction: (action: ToolbarAction) => void;
|
||||||
|
}) {
|
||||||
|
const isBtnDisabled =
|
||||||
|
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
default:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAction(action)}
|
||||||
|
disabled={isBtnDisabled || globalDisabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
variantClasses[action.type || 'default'],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
value: controlledValue,
|
||||||
|
defaultValue = '',
|
||||||
|
onChange,
|
||||||
|
placeholder: placeholderProp,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
autoFocus = false,
|
||||||
|
minRows = 4,
|
||||||
|
maxRows = 12,
|
||||||
|
maxLength,
|
||||||
|
className,
|
||||||
|
showCount = false,
|
||||||
|
showClear = true,
|
||||||
|
allowCopy = false,
|
||||||
|
rules = [],
|
||||||
|
validateTrigger = 'onAction',
|
||||||
|
actions = [],
|
||||||
|
topExtra,
|
||||||
|
title,
|
||||||
|
externalError,
|
||||||
|
onClear,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const placeholder = placeholderProp ?? '请输入文本';
|
||||||
|
|
||||||
|
const isControlled = controlledValue !== undefined;
|
||||||
|
const value = isControlled ? controlledValue : internalValue;
|
||||||
|
const displayError = externalError ?? error;
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
||||||
|
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
const textArea = internalRef.current;
|
||||||
|
if (!textArea) return;
|
||||||
|
|
||||||
|
textArea.style.height = 'auto';
|
||||||
|
|
||||||
|
const computedMin = minRows * 24;
|
||||||
|
const computedMax = maxRows * 24;
|
||||||
|
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
|
||||||
|
|
||||||
|
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
|
||||||
|
}, [minRows, maxRows]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, [value, adjustHeight]);
|
||||||
|
|
||||||
|
const validate = useCallback(
|
||||||
|
(val: string, trigger?: string): boolean => {
|
||||||
|
if (validateTrigger !== trigger && trigger) return true;
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.validator(val)) {
|
||||||
|
setError(rule.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[rules, validateTrigger],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
if (maxLength && newVal.length > maxLength) {
|
||||||
|
const msg = `内容不能超过 ${maxLength} 个字符`;
|
||||||
|
setError(msg);
|
||||||
|
toast.warning(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isControlled) setInternalValue(newVal);
|
||||||
|
onChange?.(newVal);
|
||||||
|
|
||||||
|
if (error) setError('');
|
||||||
|
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
if (!isControlled) setInternalValue('');
|
||||||
|
onChange?.('');
|
||||||
|
setError('');
|
||||||
|
internalRef.current?.focus();
|
||||||
|
toast.success('已清空');
|
||||||
|
onClear?.();
|
||||||
|
}, [isControlled, onChange, onClear]);
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
(action: ToolbarAction) => {
|
||||||
|
const isDisabled =
|
||||||
|
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
||||||
|
if (isDisabled || disabled) return;
|
||||||
|
|
||||||
|
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
||||||
|
|
||||||
|
action.onClick(value, {
|
||||||
|
clear: handleClear,
|
||||||
|
setError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value, disabled, validate, validateTrigger, handleClear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const topActions = actions.filter((a) => a.position !== 'bottom');
|
||||||
|
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
||||||
|
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
||||||
|
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
||||||
|
{hasTopBar && (
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
||||||
|
{topExtra}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{topActions.map((action) => (
|
||||||
|
<ActionButton
|
||||||
|
key={action.key}
|
||||||
|
action={action}
|
||||||
|
value={value}
|
||||||
|
globalDisabled={disabled}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showCount && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{value.length}
|
||||||
|
{maxLength ? ` / ${maxLength}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
|
||||||
|
displayError &&
|
||||||
|
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={internalRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasBottomBar && (
|
||||||
|
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{bottomActions.map((action) => (
|
||||||
|
<ActionButton
|
||||||
|
key={action.key}
|
||||||
|
action={action}
|
||||||
|
value={value}
|
||||||
|
globalDisabled={disabled}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||||
|
{allowCopy && value && (
|
||||||
|
<CopyButton text={value} tooltip="复制内容" size="sm" className="h-7 w-7 p-1" />
|
||||||
|
)}
|
||||||
|
{showClear && value && !disabled && !readOnly && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label="清空"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayError && (
|
||||||
|
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
{displayError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TextInputArea.displayName = 'TextInputArea';
|
||||||
|
|
||||||
|
export default TextInputArea;
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
// unmock the globally-mocked component so we test the real implementation
|
||||||
|
vi.unmock('@/components/CopyButton');
|
||||||
|
|
||||||
|
vi.mock('@/utils/clipboard', () => ({
|
||||||
|
copyTextToClipboard: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const mockedCopy = vi.mocked(copyTextToClipboard);
|
||||||
|
const mockedToast = vi.mocked(toast);
|
||||||
|
|
||||||
|
describe('CopyButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="hello world" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('hello world');
|
||||||
|
expect(mockedToast.success).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制空文本时弹出 error toast', async () => {
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).not.toHaveBeenCalled();
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败时弹出 error toast', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(false);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="something" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledWith('something');
|
||||||
|
expect(mockedToast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('初始渲染时带有默认 aria-label', () => {
|
||||||
|
render(<CopyButton text="initial" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('aria-label', '复制');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
|
||||||
|
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('title', '自定义提示');
|
||||||
|
expect(button).toHaveAttribute('aria-label', '自定义提示');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('className 被正确透传到按钮', () => {
|
||||||
|
render(<CopyButton text="class-test" className="my-custom-class" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).toContain('my-custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击事件阻止冒泡', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
const parentClick = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div onClick={parentClick}>
|
||||||
|
<CopyButton text="stop-propagation" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalled();
|
||||||
|
expect(parentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
const { unmount } = render(<CopyButton text="unmount-test" />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('快速连续点击不会创建多个重叠定时器', async () => {
|
||||||
|
mockedCopy.mockResolvedValue(true);
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
|
render(<CopyButton text="rapid-click" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
await user.click(button);
|
||||||
|
|
||||||
|
expect(mockedCopy).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他 button props 通过 ...props 透传', () => {
|
||||||
|
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('data-testid', 'copy-btn');
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(button).toHaveAttribute('id', 'copy-button-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||||
|
|
||||||
|
describe('EmptyPlaceholder 组件', () => {
|
||||||
|
it('应渲染字符串提示文本', () => {
|
||||||
|
render(<EmptyPlaceholder>请输入内容</EmptyPlaceholder>);
|
||||||
|
|
||||||
|
expect(screen.getByText('请输入内容')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应应用规范空状态容器样式', () => {
|
||||||
|
const { container } = render(<EmptyPlaceholder>提示</EmptyPlaceholder>);
|
||||||
|
|
||||||
|
const placeholder = container.firstChild;
|
||||||
|
expect(placeholder).toHaveClass(
|
||||||
|
'rounded-xl',
|
||||||
|
'bg-muted/30',
|
||||||
|
'border-dashed',
|
||||||
|
'border-border/80',
|
||||||
|
'min-h-[120px]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 className 自定义容器样式', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<EmptyPlaceholder className="flex-1 min-h-[320px]">提示</EmptyPlaceholder>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveClass('flex-1', 'min-h-[320px]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 messageClassName 自定义文本样式', () => {
|
||||||
|
render(<EmptyPlaceholder messageClassName="text-sm max-w-none">提示</EmptyPlaceholder>);
|
||||||
|
|
||||||
|
expect(screen.getByText('提示')).toHaveClass('text-sm', 'max-w-none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 ReactNode 类型的 children', () => {
|
||||||
|
render(
|
||||||
|
<EmptyPlaceholder>
|
||||||
|
<span data-testid="custom-content">自定义内容</span>
|
||||||
|
</EmptyPlaceholder>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('custom-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
|
||||||
|
// 用于触发错误的测试子组件
|
||||||
|
function ThrowError({ message }: { message: string }): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常渲染的子组件
|
||||||
|
function NormalComponent({ text }: { text: string }) {
|
||||||
|
return <div data-testid="normal-content">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ErrorBoundary', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 抑制测试中故意抛出的错误日志
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('正常渲染子组件', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<NormalComponent text="正常内容" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('子组件抛出错误时显示错误 UI', () => {
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="测试错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('children 变化时重置错误状态', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="初始错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 切换到正常子组件
|
||||||
|
rerender(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<NormalComponent text="恢复后的内容" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相同的 children 不重置错误状态', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="相同子组件错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 用相同的 children rerender
|
||||||
|
rerender(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="相同子组件错误" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误 UI 包含刷新按钮', () => {
|
||||||
|
const reloadMock = vi.fn();
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { reload: reloadMock },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThrowError message="按钮测试" />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
|
||||||
|
expect(refreshButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
refreshButton.click();
|
||||||
|
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { PageErrorBoundary } from '@/components/PageErrorBoundary';
|
||||||
|
|
||||||
|
// 用于触发错误的测试子组件
|
||||||
|
function ThrowError({ message }: { message: string }): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常渲染的子组件
|
||||||
|
function NormalComponent({ text }: { text: string }) {
|
||||||
|
return <div data-testid="normal-content">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PageErrorBoundary', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 抑制测试中故意抛出的错误日志
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('正常渲染子组件', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<NormalComponent text="正常内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('子组件抛出错误时显示错误卡片 UI', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="测试错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击重试按钮后恢复', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="可恢复错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 将子组件替换为正常组件,然后点击重试
|
||||||
|
rerender(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<NormalComponent text="恢复后的内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||||
|
retryButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetKey 变化时自动重置错误状态', async () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PageErrorBoundary resetKey="page-a">
|
||||||
|
<ThrowError message="页面 A 错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 切换 resetKey,同时提供正常子组件
|
||||||
|
rerender(
|
||||||
|
<PageErrorBoundary resetKey="page-b">
|
||||||
|
<NormalComponent text="页面 B 内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetKey 不变时保持错误状态', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<PageErrorBoundary resetKey="page-a">
|
||||||
|
<ThrowError message="初始错误" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 仅 children 变化,resetKey 不变,错误应保持
|
||||||
|
rerender(
|
||||||
|
<PageErrorBoundary resetKey="page-a">
|
||||||
|
<NormalComponent text="新内容" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误 UI 包含重试按钮', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="按钮测试" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||||
|
expect(retryButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('错误信息以 monospace 格式显示', () => {
|
||||||
|
render(
|
||||||
|
<PageErrorBoundary>
|
||||||
|
<ThrowError message="格式化测试" />
|
||||||
|
</PageErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorText = screen.getByText(/格式化测试/);
|
||||||
|
expect(errorText).toBeInTheDocument();
|
||||||
|
expect(errorText.tagName.toLowerCase()).toBe('pre');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
|
|
||||||
|
describe('PageSkeleton 组件', () => {
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('默认应渲染 dashboard 骨架屏', () => {
|
||||||
|
const { container } = render(<PageSkeleton />);
|
||||||
|
|
||||||
|
// dashboard 骨架屏包含 6 个卡片
|
||||||
|
const cards = container.querySelectorAll('.rounded-xl');
|
||||||
|
expect(cards.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||||
|
|
||||||
|
// 每个卡片有 2 个骨架元素(图标、文本),6 个卡片共 12 个
|
||||||
|
const cards = container.querySelectorAll('.rounded-xl');
|
||||||
|
expect(cards.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('variant 为 tool 时应渲染工具页面骨架', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="tool" />);
|
||||||
|
|
||||||
|
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('布局结构测试', () => {
|
||||||
|
it('dashboard 骨架屏应使用 grid 布局', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||||
|
const gridContainer = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(gridContainer).toHaveClass('grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool 骨架屏应有内边距', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="tool" />);
|
||||||
|
const toolContainer = container.firstChild as HTMLElement;
|
||||||
|
|
||||||
|
expect(toolContainer).toHaveClass('p-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('骨架屏元素测试', () => {
|
||||||
|
it('dashboard 骨架屏应包含圆角和边框样式', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||||
|
|
||||||
|
// 获取第一个卡片容器
|
||||||
|
const card = container.querySelector('.rounded-xl.border');
|
||||||
|
expect(card).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tool 骨架屏应包含动画脉冲效果', () => {
|
||||||
|
const { container } = render(<PageSkeleton variant="tool" />);
|
||||||
|
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const mockRouterValue = {
|
||||||
|
currentPage: 'dashboard' as PageType,
|
||||||
|
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||||
|
pageOrder: ['timestamp'] as PageType[],
|
||||||
|
isLoaded: true,
|
||||||
|
navigateTo: vi.fn(),
|
||||||
|
syncNavigation: vi.fn(),
|
||||||
|
goHome: 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(<RouterProvider>{ui}</RouterProvider>);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||||
|
mockRouterValue.isLoaded = false;
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
// 骨架屏使用 animate-pulse 类
|
||||||
|
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||||
|
mockRouterValue.isLoaded = true;
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('动画类测试', () => {
|
||||||
|
it('在 dashboard 页面应应用 dashboard 动画类', () => {
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
const box = document.querySelector('.page-transition-dashboard');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('在非 dashboard 页面应应用 enter 动画类', () => {
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
renderWithProvider(<RouterContainer />);
|
||||||
|
const box = document.querySelector('.page-transition-enter');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('路由处理测试', () => {
|
||||||
|
it('currentPage 变化时应更新', () => {
|
||||||
|
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
|
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>);
|
||||||
|
|
||||||
|
const box = document.querySelector('.page-transition-enter');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('页面级错误隔离', () => {
|
||||||
|
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
|
||||||
|
mockRouterValue.isLoaded = true;
|
||||||
|
mockRouterValue.currentPage = 'dashboard';
|
||||||
|
const { container } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
|
// 验证 RouterContainer 的 Box 结构存在
|
||||||
|
const routerBox = container.querySelector('.page-transition-dashboard');
|
||||||
|
expect(routerBox).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { StorageCleanerConfirm } from '@/pages/StorageCleaner/components/StorageCleanerConfirm';
|
||||||
|
import type { StorageCleanerOptions } from '@/types/storage';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||||
|
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||||
|
|
||||||
|
describe('StorageCleanerConfirm 组件', () => {
|
||||||
|
const mockOnClose = vi.fn();
|
||||||
|
const mockOnConfirm = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: true,
|
||||||
|
serviceWorkers: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = (props?: Partial<React.ComponentProps<typeof StorageCleanerConfirm>>) => {
|
||||||
|
return render(
|
||||||
|
<StorageCleanerConfirm
|
||||||
|
open={true}
|
||||||
|
onClose={mockOnClose}
|
||||||
|
onConfirm={mockOnConfirm}
|
||||||
|
options={defaultOptions}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('open 为 true 时应渲染对话框', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByRole('heading', { name: /确认清理/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应显示警告信息', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText(/不可撤销/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应将选中的选项显示为标签', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应显示取消和确认按钮', () => {
|
||||||
|
renderComponent();
|
||||||
|
expect(screen.getByRole('button', { name: /取消/ })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /确认清理/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('点击取消时应调用 onClose', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /取消/ }));
|
||||||
|
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击确认时应调用 onConfirm', () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
|
||||||
|
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('选项过滤测试', () => {
|
||||||
|
it('应仅显示选中的选项', () => {
|
||||||
|
const partialOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: true,
|
||||||
|
sessionStorage: false,
|
||||||
|
indexedDB: true,
|
||||||
|
cookies: false,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: partialOptions });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Local Storage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Session Storage/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Cookies$/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应处理空选项', () => {
|
||||||
|
const emptyOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: false,
|
||||||
|
sessionStorage: false,
|
||||||
|
indexedDB: false,
|
||||||
|
cookies: false,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: emptyOptions });
|
||||||
|
|
||||||
|
const chips = screen.queryAllByRole('button');
|
||||||
|
expect(chips.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('对话框行为测试', () => {
|
||||||
|
it('open 为 false 时不应渲染', () => {
|
||||||
|
renderComponent({ open: false });
|
||||||
|
expect(screen.queryByRole('heading', { name: /确认清理/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应使用不同选项渲染', () => {
|
||||||
|
const customOptions: StorageCleanerOptions = {
|
||||||
|
localStorage: false,
|
||||||
|
sessionStorage: true,
|
||||||
|
indexedDB: false,
|
||||||
|
cookies: true,
|
||||||
|
cacheStorage: false,
|
||||||
|
serviceWorkers: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent({ options: customOptions });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Session Storage/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Cookies/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
|
||||||
|
describe('SwitchButtonGroup 组件', () => {
|
||||||
|
const options = [
|
||||||
|
{ value: 'a', label: '选项A' },
|
||||||
|
{ value: 'b', label: '选项B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('应渲染所有选项按钮', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应高亮当前选中的按钮', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const buttonA = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
const buttonB = screen.getByRole('button', { name: /选项B/i });
|
||||||
|
|
||||||
|
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||||
|
expect(buttonB).toHaveClass('hover:bg-background/50');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击未选中按钮时应触发 onChange 并传入选中值', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
|
||||||
|
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击已选中按钮时不应触发 onChange', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持通过 className 自定义样式', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} className="custom-group" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const group = container.firstChild;
|
||||||
|
expect(group).toHaveClass('custom-group');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 size 属性', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} size="small" />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toHaveClass('text-xs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 buttonSx 自定义按钮样式', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应支持 ReactNode 类型的 label', () => {
|
||||||
|
const nodeOptions = [{ value: 'x', label: <span data-testid="custom-label">自定义</span> }];
|
||||||
|
render(<SwitchButtonGroup value="x" options={nodeOptions} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('custom-label')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认按钮样式应禁止文字换行', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toHaveClass('whitespace-nowrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buttonSx 传入时应覆盖默认换行样式', () => {
|
||||||
|
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: /选项A/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('number 类型支持', () => {
|
||||||
|
const numberOptions = [
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 4, label: '4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('应支持 number 类型的 value 渲染', () => {
|
||||||
|
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /^2$/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /^4$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应高亮 number 类型的当前选中项', () => {
|
||||||
|
render(<SwitchButtonGroup value={4} options={numberOptions} onChange={vi.fn()} />);
|
||||||
|
|
||||||
|
const button2 = screen.getByRole('button', { name: /^2$/i });
|
||||||
|
const button4 = screen.getByRole('button', { name: /^4$/i });
|
||||||
|
|
||||||
|
expect(button2).toHaveClass('hover:bg-background/50');
|
||||||
|
expect(button4).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击 number 选项时应传回 number 值', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^4$/i }));
|
||||||
|
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
|
|
||||||
|
describe('TextInputArea 组件', () => {
|
||||||
|
describe('基础渲染', () => {
|
||||||
|
it('应渲染 placeholder', () => {
|
||||||
|
render(<TextInputArea placeholder="请输入文本..." />);
|
||||||
|
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染传入的 value', () => {
|
||||||
|
render(<TextInputArea value="测试内容" onChange={() => {}} />);
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
expect(textarea).toHaveValue('测试内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('默认显示清空按钮', () => {
|
||||||
|
render(<TextInputArea value="有内容" onChange={() => {}} />);
|
||||||
|
expect(screen.getByRole('button', { name: '清空' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无内容时清空按钮应隐藏', () => {
|
||||||
|
render(<TextInputArea value="" onChange={() => {}} />);
|
||||||
|
expect(screen.queryByRole('button', { name: '清空' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled 时清空按钮应隐藏', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||||
|
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readOnly 时清空按钮应隐藏', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||||
|
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showClear=false 时不显示清空按钮', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
|
||||||
|
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('受控模式', () => {
|
||||||
|
it('输入时触发 onChange', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="" onChange={handleChange} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('新内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清空按钮触发 onChange("")', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="内容" onChange={handleChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('非受控模式', () => {
|
||||||
|
it('defaultValue 应显示初始值', () => {
|
||||||
|
render(<TextInputArea defaultValue="初始值" />);
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('初始值');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('输入后应更新内部值', () => {
|
||||||
|
render(<TextInputArea defaultValue="" />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||||
|
|
||||||
|
expect(textarea).toHaveValue('新内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('清空按钮应清空内容', () => {
|
||||||
|
render(<TextInputArea defaultValue="内容" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allowCopy 复制功能', () => {
|
||||||
|
it('allowCopy 且有内容时显示复制按钮', () => {
|
||||||
|
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
|
||||||
|
expect(screen.getByRole('button', { name: '复制内容' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowCopy 但无内容时隐藏复制按钮', () => {
|
||||||
|
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
|
||||||
|
expect(screen.queryByRole('button', { name: '复制内容' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowCopy=false 时不显示复制按钮', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制时调用 clipboard writeText', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '复制内容' }));
|
||||||
|
|
||||||
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeTextSpy = vi
|
||||||
|
.spyOn(navigator.clipboard, 'writeText')
|
||||||
|
.mockRejectedValue(new Error('失败'));
|
||||||
|
|
||||||
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '复制内容' }));
|
||||||
|
|
||||||
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showCount 字符计数', () => {
|
||||||
|
it('显示当前字符数', () => {
|
||||||
|
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空内容时显示 0', () => {
|
||||||
|
render(<TextInputArea value="" onChange={() => {}} showCount />);
|
||||||
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('设置 maxLength 时显示计数上限', () => {
|
||||||
|
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
|
||||||
|
expect(screen.getByText('2 / 10')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maxLength', () => {
|
||||||
|
it('超出 maxLength 的输入应被截断', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '123456' } });
|
||||||
|
|
||||||
|
expect(handleChange).not.toHaveBeenCalledWith('123456');
|
||||||
|
expect(screen.getByText('内容不能超过 5 个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未超出 maxLength 的输入应正常触发', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: '123' } });
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('验证规则', () => {
|
||||||
|
it('onChange 触发时验证失败应设置 error', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'ab' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onBlur 触发时验证失败应设置 error', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onBlur"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.blur(textarea);
|
||||||
|
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('验证通过不应显示错误', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="abc"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'abcd' } });
|
||||||
|
|
||||||
|
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onAction 触发时验证失败应阻止 action 执行', () => {
|
||||||
|
const handleAction = vi.fn();
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onAction"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
key: 'test',
|
||||||
|
label: '执行',
|
||||||
|
onClick: handleAction,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('执行'));
|
||||||
|
|
||||||
|
expect(handleAction).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('操作栏 actions', () => {
|
||||||
|
it('应渲染顶部操作按钮', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('顶部操作')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染底部操作按钮', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[
|
||||||
|
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('底部操作')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击操作按钮触发 onClick', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('操作'));
|
||||||
|
|
||||||
|
expect(handleClick).toHaveBeenCalledWith(
|
||||||
|
'内容',
|
||||||
|
expect.objectContaining({
|
||||||
|
clear: expect.any(Function),
|
||||||
|
setError: expect.any(Function),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled 为 true 时按钮应禁用', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('操作')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled 为函数且返回 true 时按钮应禁用', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('操作')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('primary 类型按钮应使用 contained 样式', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="内容"
|
||||||
|
onChange={() => {}}
|
||||||
|
actions={[
|
||||||
|
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByText('主要');
|
||||||
|
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('title', () => {
|
||||||
|
it('应渲染 title', () => {
|
||||||
|
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
|
||||||
|
expect(screen.getByText('输入区域')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不设置 title 时不渲染标题', () => {
|
||||||
|
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
|
||||||
|
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('disabled 和 readOnly', () => {
|
||||||
|
it('disabled 时输入框应禁用', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||||
|
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readOnly 时输入框应只读', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||||
|
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoFocus', () => {
|
||||||
|
it('autoFocus 应自动聚焦', () => {
|
||||||
|
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
|
||||||
|
expect(document.activeElement).toBe(screen.getByRole('textbox'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('复制功能', () => {
|
||||||
|
it('复制成功时调用 clipboard writeText', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||||
|
await user.click(screen.getByRole('button', { name: '复制内容' }));
|
||||||
|
|
||||||
|
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onClear 回调', () => {
|
||||||
|
it('点击清空按钮时应调用 onClear', () => {
|
||||||
|
const handleClear = vi.fn();
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||||
|
|
||||||
|
expect(handleClear).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不传 onClear 时清空按钮应正常工作', () => {
|
||||||
|
render(<TextInputArea defaultValue="内容" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '清空' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('externalError 外部错误', () => {
|
||||||
|
it('设置 externalError 时应显示错误状态', () => {
|
||||||
|
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externalError 为空时应隐藏错误状态', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('错误')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externalError 优先级高于内部验证错误', () => {
|
||||||
|
render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
externalError="外部错误"
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('externalError 清除后应显示内部验证错误', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
externalError="外部错误"
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<TextInputArea
|
||||||
|
value="ab"
|
||||||
|
onChange={() => {}}
|
||||||
|
validateTrigger="onChange"
|
||||||
|
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const textarea = screen.getByRole('textbox');
|
||||||
|
fireEvent.change(textarea, { target: { value: 'a' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('autoResize', () => {
|
||||||
|
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = container.querySelector('textarea');
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('autoResize=false 时设置固定 rows', () => {
|
||||||
|
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||||
|
|
||||||
|
const textarea = container.querySelector('textarea');
|
||||||
|
expect(textarea).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('样式集成', () => {
|
||||||
|
it('应透传 className', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# components/ui/
|
||||||
|
|
||||||
|
shadcn/ui 基础原子组件目录,基于 Radix UI 原语 + Tailwind CSS 实现。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| `button.tsx` | 按钮组件,支持 `default/destructive/outline/secondary/ghost/link` 变体和 `default/sm/lg/icon` 尺寸 |
|
||||||
|
| `input.tsx` | 标准输入框,统一的 ring/focus 样式 |
|
||||||
|
| `select.tsx` | 下拉选择组件,包含 Trigger、Content、Item 等子组件 |
|
||||||
|
| `dialog.tsx` | 对话框组件,包含 Overlay、Content、Header、Footer、Title、Description |
|
||||||
|
| `checkbox.tsx` | 复选框组件 |
|
||||||
|
| `label.tsx` | 标签组件 |
|
||||||
|
| `switch.tsx` | 开关组件 |
|
||||||
|
| `badge.tsx` | 徽章组件,支持 `default/secondary/destructive/outline` 变体 |
|
||||||
|
|
||||||
|
## 使用方式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 添加新组件
|
||||||
|
|
||||||
|
使用 shadcn/ui CLI 添加新组件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx shadcn-ui@latest add <component-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
组件配置在项目根目录的 `components.json` 中定义。
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-9 rounded-md px-3',
|
||||||
|
lg: 'h-11 rounded-md px-8',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
));
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Checkbox };
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
interface DialogContentProps extends React.ComponentPropsWithoutRef<
|
||||||
|
typeof DialogPrimitive.Content
|
||||||
|
> {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
DialogContentProps
|
||||||
|
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Toaster as Sonner } from 'sonner';
|
||||||
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||||
|
|
||||||
|
export function Toaster(props: ToasterProps) {
|
||||||
|
const { resolvedMode } = useThemeMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={resolvedMode}
|
||||||
|
className="toaster group"
|
||||||
|
position="bottom-center"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||||
|
description: 'group-[.toast]:text-muted-foreground',
|
||||||
|
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
|
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const pageLoadTracker = vi.hoisted(() => ({
|
||||||
|
loaded: [] as string[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/pages/Dashboard', () => {
|
||||||
|
pageLoadTracker.loaded.push('Dashboard');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/Timestamp', () => {
|
||||||
|
pageLoadTracker.loaded.push('Timestamp');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/StorageCleaner', () => {
|
||||||
|
pageLoadTracker.loaded.push('StorageCleaner');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/QrCode', () => {
|
||||||
|
pageLoadTracker.loaded.push('QrCode');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/TextStatistics', () => {
|
||||||
|
pageLoadTracker.loaded.push('TextStatistics');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/Jwt', () => {
|
||||||
|
pageLoadTracker.loaded.push('Jwt');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/JsonTools', () => {
|
||||||
|
pageLoadTracker.loaded.push('JsonTools');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/Base64Converter', () => {
|
||||||
|
pageLoadTracker.loaded.push('Base64Converter');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/RightClickRestorer', () => {
|
||||||
|
pageLoadTracker.loaded.push('RightClickRestorer');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
vi.mock('@/pages/TestDataGenerator', () => {
|
||||||
|
pageLoadTracker.loaded.push('TestDataGenerator');
|
||||||
|
return { default: () => null };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('features 懒加载', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
pageLoadTracker.loaded.length = 0;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('仅导入工具函数时不应加载任何页面模块', async () => {
|
||||||
|
const { getAllFeatureKeys, getDefaultPageOrder } = await import('@/config/features');
|
||||||
|
|
||||||
|
getAllFeatureKeys();
|
||||||
|
getDefaultPageOrder();
|
||||||
|
|
||||||
|
expect(pageLoadTracker.loaded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('访问 FEATURES 元数据时不应加载任何页面模块', async () => {
|
||||||
|
const { FEATURES } = await import('@/config/features');
|
||||||
|
|
||||||
|
expect(FEATURES).toHaveLength(10);
|
||||||
|
expect(pageLoadTracker.loaded).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('渲染 lazy 组件时才应加载对应页面模块', async () => {
|
||||||
|
const React = await import('react');
|
||||||
|
const { render, waitFor } = await import('@testing-library/react');
|
||||||
|
const { FEATURES } = await import('@/config/features');
|
||||||
|
|
||||||
|
const DashboardPage = FEATURES.find((f) => f.key === 'dashboard')!.components.popup;
|
||||||
|
|
||||||
|
render(
|
||||||
|
React.createElement(React.Suspense, { fallback: null }, React.createElement(DashboardPage)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(pageLoadTracker.loaded).toEqual(['Dashboard']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
FEATURES,
|
||||||
|
getAllFeatureKeys,
|
||||||
|
getDefaultPageOrder,
|
||||||
|
getDefaultVisibleFeatureKeys,
|
||||||
|
getFeatureByKey,
|
||||||
|
} from '@/config/features';
|
||||||
|
|
||||||
|
describe('features', () => {
|
||||||
|
describe('FEATURES', () => {
|
||||||
|
it('应该有10个功能定义', () => {
|
||||||
|
expect(FEATURES).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该有每个功能的所有必需属性', () => {
|
||||||
|
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('tab');
|
||||||
|
|
||||||
|
if (feature.key !== 'dashboard') {
|
||||||
|
expect(feature).toHaveProperty('icon');
|
||||||
|
expect(feature).toHaveProperty('themeColorKey');
|
||||||
|
expect(typeof feature.themeColorKey).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该有每个功能的唯一key', () => {
|
||||||
|
const keys = FEATURES.map((f) => f.key);
|
||||||
|
const uniqueKeys = new Set(keys);
|
||||||
|
expect(uniqueKeys.size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFeatureByKey', () => {
|
||||||
|
it('应该返回dashboard功能', () => {
|
||||||
|
const feature = getFeatureByKey('dashboard');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('dashboard');
|
||||||
|
expect(feature?.label).toBe('仪表盘');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回时间戳功能', () => {
|
||||||
|
const feature = getFeatureByKey('timestamp');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('timestamp');
|
||||||
|
expect(feature?.label).toBe('时间戳');
|
||||||
|
expect(feature?.themeColorKey).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回存储清理功能', () => {
|
||||||
|
const feature = getFeatureByKey('storageCleaner');
|
||||||
|
expect(feature).toBeDefined();
|
||||||
|
expect(feature?.key).toBe('storageCleaner');
|
||||||
|
expect(feature?.label).toBe('存储清理');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该返回undefined用于无效的key', () => {
|
||||||
|
const feature = getFeatureByKey('invalid' as any);
|
||||||
|
expect(feature).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultVisibleFeatureKeys', () => {
|
||||||
|
it('应该返回仅可见的功能', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
visibleKeys.forEach((key) => {
|
||||||
|
const feature = getFeatureByKey(key);
|
||||||
|
expect(feature?.defaultVisible).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该包含仪表盘、时间戳、存储清理、二维码', () => {
|
||||||
|
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||||
|
expect(visibleKeys).toContain('dashboard');
|
||||||
|
expect(visibleKeys).toContain('timestamp');
|
||||||
|
expect(visibleKeys).toContain('storageCleaner');
|
||||||
|
expect(visibleKeys).toContain('qrCode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllFeatureKeys', () => {
|
||||||
|
it('应该返回所有功能key', () => {
|
||||||
|
const allKeys = getAllFeatureKeys();
|
||||||
|
expect(allKeys).toHaveLength(10);
|
||||||
|
expect(allKeys).toContain('dashboard');
|
||||||
|
expect(allKeys).toContain('timestamp');
|
||||||
|
expect(allKeys).toContain('storageCleaner');
|
||||||
|
expect(allKeys).toContain('qrCode');
|
||||||
|
expect(allKeys).toContain('textStatistics');
|
||||||
|
expect(allKeys).toContain('jwt');
|
||||||
|
expect(allKeys).toContain('jsonTools');
|
||||||
|
expect(allKeys).toContain('base64Converter');
|
||||||
|
expect(allKeys).toContain('rightClickRestorer');
|
||||||
|
expect(allKeys).toContain('testDataGenerator');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDefaultPageOrder', () => {
|
||||||
|
it('应该排除仪表盘从页面顺序', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).not.toContain('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该包含时间戳、存储清理、二维码在页面顺序', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).toContain('timestamp');
|
||||||
|
expect(pageOrder).toContain('storageCleaner');
|
||||||
|
expect(pageOrder).toContain('qrCode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该有9个项目在页面顺序', () => {
|
||||||
|
const pageOrder = getDefaultPageOrder();
|
||||||
|
expect(pageOrder).toHaveLength(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { type ComponentType, lazy } from 'react';
|
||||||
|
import type { LucideProps } from 'lucide-react';
|
||||||
|
import type { PageType } from '@/types/storage';
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Database,
|
||||||
|
QrCode,
|
||||||
|
FileText,
|
||||||
|
Key,
|
||||||
|
GitCompareArrows,
|
||||||
|
ArrowLeftRight,
|
||||||
|
MousePointerClick,
|
||||||
|
FileSpreadsheet,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
|
|
||||||
|
// 懒加载页面组件
|
||||||
|
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
|
||||||
|
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
|
||||||
|
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
|
||||||
|
const QrCodePage = lazy(() => import('@/pages/QrCode'));
|
||||||
|
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
||||||
|
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
||||||
|
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
||||||
|
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||||
|
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
|
||||||
|
const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator'));
|
||||||
|
|
||||||
|
export interface FeatureConfig {
|
||||||
|
key: PageType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
themeColorKey?: PaletteColorKey;
|
||||||
|
icon?: ComponentType<LucideProps>;
|
||||||
|
defaultVisible: boolean;
|
||||||
|
components: {
|
||||||
|
popup: ComponentType;
|
||||||
|
sidepanel: ComponentType;
|
||||||
|
tab: ComponentType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEATURES: FeatureConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'dashboard',
|
||||||
|
label: '仪表盘',
|
||||||
|
description: '',
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: DashboardPage,
|
||||||
|
sidepanel: DashboardPage,
|
||||||
|
tab: DashboardPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: '时间戳',
|
||||||
|
description: 'Unix 毫秒数转换与格式化',
|
||||||
|
themeColorKey: 'primary',
|
||||||
|
icon: Clock,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: TimestampPage,
|
||||||
|
sidepanel: TimestampPage,
|
||||||
|
tab: TimestampPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'storageCleaner',
|
||||||
|
label: '存储清理',
|
||||||
|
description: '清理缓存、Cookies 及本地存储',
|
||||||
|
themeColorKey: 'warning',
|
||||||
|
icon: Database,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: StorageCleanerPage,
|
||||||
|
sidepanel: StorageCleanerPage,
|
||||||
|
tab: StorageCleanerPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'qrCode',
|
||||||
|
label: '二维码工具',
|
||||||
|
description: '生成当前选中的 URL 的二维码',
|
||||||
|
themeColorKey: 'success',
|
||||||
|
icon: QrCode,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: QrCodePage,
|
||||||
|
sidepanel: QrCodePage,
|
||||||
|
tab: QrCodePage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'textStatistics',
|
||||||
|
label: '文本统计',
|
||||||
|
description: '实时分析文本字符、单词及字节',
|
||||||
|
themeColorKey: 'secondary',
|
||||||
|
icon: FileText,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: TextStatisticsPage,
|
||||||
|
sidepanel: TextStatisticsPage,
|
||||||
|
tab: TextStatisticsPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jwt',
|
||||||
|
label: 'JWT 解析',
|
||||||
|
description: 'JSON Web Token 解码与查看',
|
||||||
|
themeColorKey: 'info',
|
||||||
|
icon: Key,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: JwtPage,
|
||||||
|
sidepanel: JwtPage,
|
||||||
|
tab: JwtPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jsonTools',
|
||||||
|
label: 'JSON 工具',
|
||||||
|
description: '差异比较、格式化、YAML/TOML 转换及压缩',
|
||||||
|
themeColorKey: 'primary',
|
||||||
|
icon: GitCompareArrows,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: JsonToolsPage,
|
||||||
|
sidepanel: JsonToolsPage,
|
||||||
|
tab: JsonToolsPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'base64Converter',
|
||||||
|
label: 'Base64 转换器',
|
||||||
|
description: '文本、文件与图像的 Base64 编码转换',
|
||||||
|
themeColorKey: 'info',
|
||||||
|
icon: ArrowLeftRight,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: Base64ConverterPage,
|
||||||
|
sidepanel: Base64ConverterPage,
|
||||||
|
tab: Base64ConverterPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rightClickRestorer',
|
||||||
|
label: '右键恢复',
|
||||||
|
description: '检测并恢复被网站禁用的浏览器右键菜单',
|
||||||
|
themeColorKey: 'success',
|
||||||
|
icon: MousePointerClick,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: RightClickRestorerPage,
|
||||||
|
sidepanel: RightClickRestorerPage,
|
||||||
|
tab: RightClickRestorerPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'testDataGenerator',
|
||||||
|
label: '测试数据生成器',
|
||||||
|
description: '自定义规则批量生成测试数据',
|
||||||
|
themeColorKey: 'warning',
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: TestDataGeneratorPage,
|
||||||
|
sidepanel: TestDataGeneratorPage,
|
||||||
|
tab: TestDataGeneratorPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||||
|
return FEATURES.find((f) => f.key === key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultVisibleFeatureKeys(): PageType[] {
|
||||||
|
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFeatureKeys(): PageType[] {
|
||||||
|
return FEATURES.map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultPageOrder(): PageType[] {
|
||||||
|
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
if (pathname.includes('sidepanel')) {
|
||||||
|
return 'sidepanel';
|
||||||
|
}
|
||||||
|
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
|
||||||
|
return 'tab';
|
||||||
|
}
|
||||||
|
return 'popup';
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
createAllContextMenus,
|
||||||
|
parseContextMenuClick,
|
||||||
|
CONTEXT_MENU_CONFIGS,
|
||||||
|
MAX_PAYLOAD_LENGTH,
|
||||||
|
} from '@/utils/contextMenu';
|
||||||
|
import { MessageAction } from '@/utils/messages';
|
||||||
|
|
||||||
|
describe('background 菜单注册与分流', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('菜单注册', () => {
|
||||||
|
it('应该调用 createAllContextMenus 创建所有菜单项', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledTimes(CONTEXT_MENU_CONFIGS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建父级菜单 Testing Tools', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'testing-tools-parent',
|
||||||
|
title: 'Testing Tools',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建 JWT 解析子菜单', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'jwt',
|
||||||
|
title: '解析 JWT',
|
||||||
|
parentId: 'testing-tools-parent',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该创建网页链接转二维码子菜单', () => {
|
||||||
|
createAllContextMenus();
|
||||||
|
|
||||||
|
expect(chrome.contextMenus.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'qrCode-page',
|
||||||
|
title: '网页链接转二维码',
|
||||||
|
contexts: ['page'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('菜单点击解析', () => {
|
||||||
|
const createMockOnClickData = (
|
||||||
|
overrides: Partial<chrome.contextMenus.OnClickData> = {},
|
||||||
|
): chrome.contextMenus.OnClickData => ({
|
||||||
|
menuItemId: 'test',
|
||||||
|
editable: false,
|
||||||
|
pageUrl: 'https://example.com',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当有 selectionText 时应返回对应的 featureKey 和 payload', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'jwt',
|
||||||
|
selectionText: 'test-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('jwt', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'jwt', payload: 'test-token' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当没有 selectionText 和 srcUrl 时应返回错误', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'unknown',
|
||||||
|
pageUrl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('unknown', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: '无法获取有效数据',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本超过最大长度限制时应截断', () => {
|
||||||
|
const longText = 'a'.repeat(MAX_PAYLOAD_LENGTH + 1000);
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'textStatistics',
|
||||||
|
selectionText: longText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data?.payload.length).toBe(MAX_PAYLOAD_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('当文本未超过最大长度限制时应保持原样', () => {
|
||||||
|
const shortText = 'short text';
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'textStatistics',
|
||||||
|
selectionText: shortText,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('textStatistics', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'textStatistics', payload: 'short text' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理页面 URL 菜单点击', () => {
|
||||||
|
const info = createMockOnClickData({
|
||||||
|
menuItemId: 'storageCleaner',
|
||||||
|
pageUrl: 'https://example.com/page',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseContextMenuClick('storageCleaner', info);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
data: { featureKey: 'storageCleaner', payload: 'https://example.com/page' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('消息类型定义', () => {
|
||||||
|
it('CONTEXT_MENU_CLICKED 消息类型应正确定义', () => {
|
||||||
|
expect(MessageAction.CONTEXT_MENU_CLICKED).toBe('contextMenuClicked');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import '../../.wxt/types/imports.d.ts';
|
||||||
|
import { browser } from 'wxt/browser';
|
||||||
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
|
||||||
|
import { saveContextMenuData } from '@/utils/useContextMenuData';
|
||||||
|
import { mainWorldInjectionScript } from '@/utils/rightClickInjection';
|
||||||
|
|
||||||
|
export default defineBackground(() => {
|
||||||
|
// 1. 扩展初次安装或更新时,注册右键上下文菜单
|
||||||
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
|
createAllContextMenus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 右键点击路由
|
||||||
|
browser.contextMenus.onClicked.addListener(async (info, _tab) => {
|
||||||
|
const result = parseContextMenuClick(info.menuItemId as string, info);
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
if (result.error) {
|
||||||
|
console.warn('[Context Menu Warning]', result.error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { featureKey, payload } = result.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||||
|
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||||
|
|
||||||
|
if (isSidePanelOpen) {
|
||||||
|
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.debug('[Context Menu] Side panel pipeline is not available:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveContextMenuData({ featureKey, payload });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.action.openPopup();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,数据已暂存:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 扩展图标点击时打开侧边栏
|
||||||
|
browser.action.onClicked.addListener(async (tab) => {
|
||||||
|
if (tab.id) {
|
||||||
|
try {
|
||||||
|
await browser.sidePanel.open({ tabId: tab.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to open side panel via extension action clicked:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 注入主环境脚本(content script 无权访问 chrome.tabs/scripting,委托 background 执行)
|
||||||
|
onMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT, async (message) => {
|
||||||
|
const sender = message.sender as chrome.runtime.MessageSender | undefined;
|
||||||
|
const tabId = sender?.tab?.id;
|
||||||
|
|
||||||
|
if (!tabId) {
|
||||||
|
console.warn('[RightClickRestorer] 注入请求缺少 tabId');
|
||||||
|
return { success: false, message: '缺少 tabId' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: mainWorldInjectionScript,
|
||||||
|
world: 'MAIN',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error('[RightClickRestorer] 执行脚本失败:', errorMsg);
|
||||||
|
return { success: false, message: errorMsg };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import '../../.wxt/types/imports.d.ts';
|
||||||
|
import { initContextMenuHandler } from './content/contextMenuHandler';
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['<all_urls>'],
|
||||||
|
runAt: 'document_end',
|
||||||
|
main() {
|
||||||
|
initContextMenuHandler();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
||||||
|
import { MessageAction, onMessage } from '@/utils/messages';
|
||||||
|
import { getTextStats } from '@/utils/textStatistics';
|
||||||
|
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||||
|
|
||||||
|
function convertTimestamp(input: string): string {
|
||||||
|
const num = Number(input.trim());
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return '无效时间戳';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1e12 区分毫秒/秒级时间戳
|
||||||
|
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
|
||||||
|
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
return '无效时间戳';
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = d.getFullYear();
|
||||||
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(d.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastClickX = 0;
|
||||||
|
let lastClickY = 0;
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
(e) => {
|
||||||
|
lastClickX = e.clientX;
|
||||||
|
lastClickY = e.clientY;
|
||||||
|
},
|
||||||
|
{ capture: true, passive: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
export function initContextMenuHandler(): void {
|
||||||
|
const dismissPopover = (): void => {
|
||||||
|
hidePopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', dismissPopover, { passive: true });
|
||||||
|
document.addEventListener('scroll', dismissPopover, { passive: true });
|
||||||
|
window.addEventListener('resize', dismissPopover, { passive: true });
|
||||||
|
|
||||||
|
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
|
||||||
|
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
|
||||||
|
|
||||||
|
switch (featureKey) {
|
||||||
|
case 'timestamp': {
|
||||||
|
const result = convertTimestamp(payload);
|
||||||
|
showTimestampResult(lastClickX, lastClickY, payload, result);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'textStatistics': {
|
||||||
|
const stats = getTextStats(payload);
|
||||||
|
showTextStatsResult(lastClickX, lastClickY, payload, stats);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
hidePopover();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
const POPOVER_ID = 'testing-tools-popover';
|
||||||
|
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
|
||||||
|
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(POPOVER_STYLE_ID)) return;
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = POPOVER_STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
#${POPOVER_ID} {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483647;
|
||||||
|
max-width: 400px;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID}.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0a0b0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #808090;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-close:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-content {
|
||||||
|
word-break: break-all;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-label {
|
||||||
|
color: #808090;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .popover-value {
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-item {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808090;
|
||||||
|
}
|
||||||
|
|
||||||
|
#${POPOVER_ID} .stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreatePopover(): HTMLElement {
|
||||||
|
let popover = document.getElementById(POPOVER_ID);
|
||||||
|
if (!popover) {
|
||||||
|
injectStyles();
|
||||||
|
popover = document.createElement('div');
|
||||||
|
popover.id = POPOVER_ID;
|
||||||
|
document.body.appendChild(popover);
|
||||||
|
}
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPopover(popover: HTMLElement, x: number, y: number): void {
|
||||||
|
const rect = popover.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = x + 8;
|
||||||
|
let top = y + 8;
|
||||||
|
|
||||||
|
if (left + rect.width > viewportWidth - 16) {
|
||||||
|
left = viewportWidth - rect.width - 16;
|
||||||
|
}
|
||||||
|
if (left < 16) left = 16;
|
||||||
|
|
||||||
|
if (top + rect.height > viewportHeight - 16) {
|
||||||
|
top = y - rect.height - 8;
|
||||||
|
}
|
||||||
|
if (top < 16) top = 16;
|
||||||
|
|
||||||
|
popover.style.left = `${left}px`;
|
||||||
|
popover.style.top = `${top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function showPopover(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
contentHtml: string,
|
||||||
|
title?: string,
|
||||||
|
duration: number = 5000,
|
||||||
|
): void {
|
||||||
|
const popover = getOrCreatePopover();
|
||||||
|
|
||||||
|
popover.innerHTML = '';
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'popover-header';
|
||||||
|
|
||||||
|
const titleSpan = document.createElement('span');
|
||||||
|
titleSpan.className = 'popover-title';
|
||||||
|
titleSpan.textContent = title;
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'popover-close';
|
||||||
|
closeBtn.innerHTML = '×';
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
header.appendChild(titleSpan);
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
popover.appendChild(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentContainer = document.createElement('div');
|
||||||
|
contentContainer.className = 'popover-content';
|
||||||
|
contentContainer.innerHTML = contentHtml;
|
||||||
|
popover.appendChild(contentContainer);
|
||||||
|
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
positionPopover(popover, x, y);
|
||||||
|
popover.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hideTimeout) clearTimeout(hideTimeout);
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
hideTimeout = setTimeout(() => {
|
||||||
|
hidePopover();
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hidePopover(): void {
|
||||||
|
const popover = document.getElementById(POPOVER_ID);
|
||||||
|
if (popover) {
|
||||||
|
popover.classList.remove('visible');
|
||||||
|
}
|
||||||
|
if (hideTimeout) {
|
||||||
|
clearTimeout(hideTimeout);
|
||||||
|
hideTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
|
||||||
|
const cleanTimestamp = escapeHtml(timestamp);
|
||||||
|
const cleanResult = escapeHtml(result);
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="popover-label">输入时间戳</div>
|
||||||
|
<div class="popover-value">${cleanTimestamp}</div>
|
||||||
|
<div class="popover-label">转换结果</div>
|
||||||
|
<div class="popover-value">${cleanResult}</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '转换结果');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showTextStatsResult(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
text: string,
|
||||||
|
stats: { characters: number; words: number; lines: number; bytes: number },
|
||||||
|
): void {
|
||||||
|
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||||
|
const cleanText = escapeHtml(truncatedText);
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<div class="popover-label">选中文本</div>
|
||||||
|
<div class="popover-value">${cleanText}</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字符</div>
|
||||||
|
<div class="stat-value">${stats.characters}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">单词</div>
|
||||||
|
<div class="stat-value">${stats.words}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">行数</div>
|
||||||
|
<div class="stat-value">${stats.lines}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">字节</div>
|
||||||
|
<div class="stat-value">${stats.bytes}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
showPopover(x, y, content, '统计结果');
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
|
import TopBar from '@/layout/TopBar';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { getEntryPointType } from '@/config/features';
|
||||||
|
export default function App() {
|
||||||
|
const entryType = getEntryPointType();
|
||||||
|
|
||||||
|
const routerConfig =
|
||||||
|
entryType === 'tab'
|
||||||
|
? {
|
||||||
|
syncKey: 'app/tabRoute' as const,
|
||||||
|
visiblePagesKey: 'app/tabVisiblePages' as const,
|
||||||
|
pageOrderKey: 'app/tabPageOrder' as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
syncKey: 'app/popupRoute' as const,
|
||||||
|
visiblePagesKey: 'app/popupVisiblePages' as const,
|
||||||
|
pageOrderKey: 'app/popupPageOrder' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterProvider
|
||||||
|
syncKey={routerConfig.syncKey}
|
||||||
|
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||||
|
pageOrderKey={routerConfig.pageOrderKey}
|
||||||
|
>
|
||||||
|
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||||
|
<TopBar />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</RouterProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Testing Tools - 标签页</title>
|
||||||
|
<meta name="manifest.type" content="browser_action" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('snapshot/app/themeMode');
|
||||||
|
var mode = raw ? JSON.parse(raw) : 'system';
|
||||||
|
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') mode = 'system';
|
||||||
|
var isDark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (isDark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (_error) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Force initial popup size before React hydration */
|
||||||
|
html, body {
|
||||||
|
width: 400px;
|
||||||
|
height: 600px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
}
|
||||||
|
/* Ensure full size for the root container */
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* If opened in a tab (mode=tab), reset the fixed size */
|
||||||
|
@media screen and (min-width: 600px) {
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import AppRoot from '@/providers/AppRoot';
|
||||||
|
import '@/index.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './style.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</AppRoot>,
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
|
let isRestored = false;
|
||||||
|
|
||||||
|
/* ======================== Main World 注入 ======================== */
|
||||||
|
|
||||||
|
async function injectMainWorldScript(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await sendMessage(MessageAction.INJECT_MAIN_WORLD_SCRIPT);
|
||||||
|
if (!response.success) {
|
||||||
|
console.error('[RightClickRestorer] Background injection failed:', response.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[RightClickRestorer] Failed to request main world injection:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== Isolated World 事件拦截 ======================== */
|
||||||
|
|
||||||
|
function installEventIntercepts(): void {
|
||||||
|
window.addEventListener(
|
||||||
|
'contextmenu',
|
||||||
|
(e) => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 部分网站在 mousedown 阶段阻止 contextmenu
|
||||||
|
window.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
(e) => {
|
||||||
|
if (!isRestored) return;
|
||||||
|
if (e.button === 2 || (e.buttons & 2) !== 0) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 激活保护 ======================== */
|
||||||
|
|
||||||
|
async function activateProtection(): Promise<void> {
|
||||||
|
if (isRestored) return;
|
||||||
|
|
||||||
|
await injectMainWorldScript();
|
||||||
|
|
||||||
|
// 再次检查,防止并发调用导致重复注册
|
||||||
|
if (isRestored) return;
|
||||||
|
|
||||||
|
installEventIntercepts();
|
||||||
|
|
||||||
|
isRestored = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 消息通信 ======================== */
|
||||||
|
|
||||||
|
function initMessaging(): void {
|
||||||
|
onMessage(MessageAction.RESTORE_RIGHT_CLICK, async () => {
|
||||||
|
await activateProtection();
|
||||||
|
return { success: true, restored: isRestored };
|
||||||
|
});
|
||||||
|
|
||||||
|
onMessage(MessageAction.QUERY_RIGHT_CLICK_STATUS, () => {
|
||||||
|
return { success: true, restored: isRestored };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================== 入口 ======================== */
|
||||||
|
|
||||||
|
export default defineContentScript({
|
||||||
|
matches: ['<all_urls>'],
|
||||||
|
runAt: 'document_start',
|
||||||
|
main() {
|
||||||
|
initMessaging();
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import RouterProvider from '@/providers/RouterProvider';
|
||||||
|
import TopBar from '@/layout/TopBar';
|
||||||
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
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">
|
||||||
|
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||||
|
<TopBar />
|
||||||
|
<ErrorBoundary>
|
||||||
|
<RouterContainer />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</RouterProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!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>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem('snapshot/app/themeMode');
|
||||||
|
var mode = raw ? JSON.parse(raw) : 'system';
|
||||||
|
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') mode = 'system';
|
||||||
|
var isDark =
|
||||||
|
mode === 'dark' ||
|
||||||
|
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (isDark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (_error) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import AppRoot from '@/providers/AppRoot';
|
||||||
|
import '@/index.css';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<AppRoot>
|
||||||
|
<App />
|
||||||
|
</AppRoot>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# layout/
|
||||||
|
|
||||||
|
应用壳层布局组件目录,存放与扩展入口(popup / sidepanel / tab)绑定的布局 UI,与 `components/` 中的通用可复用组件区分。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 目录 | 用途 |
|
||||||
|
| --------- | -------------------------------------------------------------- |
|
||||||
|
| `TopBar/` | 顶部导航栏:搜索(含历史记录)、主题切换、返回导航、标签页打开 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
遵循与 `pages/` 相同的 UI + Hook 分离模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
layout/TopBar/
|
||||||
|
├── index.tsx # 布局入口
|
||||||
|
├── useTopBar.ts # 业务逻辑 Hook
|
||||||
|
├── constants.ts # 常量
|
||||||
|
├── SearchInput.tsx # 搜索输入框
|
||||||
|
├── SearchDropdown.tsx # 搜索结果/历史下拉面板
|
||||||
|
├── SearchResultItem.tsx # 单条搜索结果项
|
||||||
|
├── TopBarActions.tsx # 右侧操作按钮组
|
||||||
|
└── __tests__/
|
||||||
|
└── index.test.tsx
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { type FeatureConfig } from '@/config/features';
|
||||||
|
import SearchResultItem from './SearchResultItem';
|
||||||
|
|
||||||
|
interface SearchDropdownProps {
|
||||||
|
searchQuery: string;
|
||||||
|
searchResults: FeatureConfig[];
|
||||||
|
recentFeatures: FeatureConfig[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (feature: FeatureConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchDropdown({
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
recentFeatures,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
}: SearchDropdownProps) {
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
const items = isSearching ? searchResults : recentFeatures;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
|
<ul role="listbox" className="p-1.5">
|
||||||
|
{!isSearching && items.length > 0 && (
|
||||||
|
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
最近搜索
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSearching && items.length === 0 ? (
|
||||||
|
<li className="px-4 py-6 text-center text-sm text-muted-foreground">未找到相关工具</li>
|
||||||
|
) : (
|
||||||
|
items.map((feature, index) => (
|
||||||
|
<SearchResultItem
|
||||||
|
key={feature.key}
|
||||||
|
feature={feature}
|
||||||
|
selected={selectedIndex === index}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { type RefObject } from 'react';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { getSearchShortcutLabel } from './constants';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchQueryChange: (value: string) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchInput({
|
||||||
|
inputRef,
|
||||||
|
searchQuery,
|
||||||
|
onSearchQueryChange,
|
||||||
|
onFocus,
|
||||||
|
onKeyDown,
|
||||||
|
onClear,
|
||||||
|
}: SearchInputProps) {
|
||||||
|
return (
|
||||||
|
<div className="group relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60 transition-colors group-focus-within:text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索工具..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
aria-label="搜索工具..."
|
||||||
|
className="h-9 rounded-lg border-border/60 bg-muted/40 pl-9 pr-16 shadow-none focus-visible:ring-1 focus-visible:ring-offset-0 placeholder:text-muted-foreground/50"
|
||||||
|
/>
|
||||||
|
{!searchQuery && (
|
||||||
|
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden h-5 -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 sm:inline-flex">
|
||||||
|
{getSearchShortcutLabel()}
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClear}
|
||||||
|
aria-label="清除搜索"
|
||||||
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||