Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(git show-ref:*)",
|
||||
"Bash(npx vitest:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git stash:*)",
|
||||
"Bash(git reset:*)",
|
||||
"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,141 @@
|
||||
# Copilot 指令
|
||||
|
||||
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
|
||||
|
||||
## 核心命令
|
||||
|
||||
```bash
|
||||
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 # TypeScript 类型检查(tsc --noEmit)
|
||||
npm run test # 运行全部单元测试(vitest run)
|
||||
npm run test:watch # Vitest 监视模式
|
||||
npm run test:coverage # 带覆盖率的测试
|
||||
```
|
||||
|
||||
运行单个测试文件:`npx vitest run path/to/file.test.ts`
|
||||
|
||||
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall` → `wxt prepare` 重新生成 `.wxt/` 类型声明)。
|
||||
|
||||
## CI 流水线(GitHub Actions)
|
||||
|
||||
严格顺序门控,任一步骤失败则终止:
|
||||
|
||||
1. **setup** — 安装依赖,缓存 `node_modules`
|
||||
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
|
||||
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
|
||||
|
||||
Pre-commit 钩子(`.husky/pre-commit` → `lint-staged`):
|
||||
|
||||
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
|
||||
2. 同一代码文件:运行 `prettier --write`
|
||||
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 路由(不使用 React Router)
|
||||
|
||||
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup`、`sidepanel`、`tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
|
||||
|
||||
存在三套独立的路由作用域:`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute`,各自维护独立的可见页面列表和页面排序。
|
||||
|
||||
### 存储
|
||||
|
||||
所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
|
||||
|
||||
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
|
||||
|
||||
### 扩展通信
|
||||
|
||||
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`。
|
||||
|
||||
### 页面组件模式
|
||||
|
||||
功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范):
|
||||
|
||||
```
|
||||
pages/FeatureName/
|
||||
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
└── constants.ts # 常量定义(可选)
|
||||
```
|
||||
|
||||
- 页面组件调用 `useI18n('featureName')` 获取翻译函数
|
||||
- Hook 负责所有状态管理,通过返回值暴露给页面
|
||||
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||
|
||||
### 新功能开发清单
|
||||
|
||||
1. 在 `types/storage.d.ts` 的 `PageType` 联合类型中添加新成员
|
||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
|
||||
3. 在 `pages/` 目录创建页面组件(懒加载):
|
||||
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
|
||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||
- `constants.ts` — 常量(可选)
|
||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
|
||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||
6. 添加对应的单元测试
|
||||
|
||||
## 关键规范
|
||||
|
||||
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
|
||||
|
||||
### 浏览器 API
|
||||
|
||||
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
|
||||
|
||||
### 路径别名
|
||||
|
||||
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
|
||||
|
||||
### UI 组件
|
||||
|
||||
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
|
||||
- 图标:`lucide-react`
|
||||
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge)
|
||||
- 主题:使用 shadcn/ui 语义化 token(`bg-background`、`text-foreground`、`border-border` 等),禁止硬编码颜色
|
||||
|
||||
### 代码分割
|
||||
|
||||
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
|
||||
|
||||
### 代码风格
|
||||
|
||||
- 禁止使用 `any`(测试文件除外)
|
||||
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`)
|
||||
- Prettier:100 字符宽、单引号、尾逗号 all、LF 换行
|
||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`
|
||||
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
|
||||
|
||||
### 测试
|
||||
|
||||
- 环境:jsdom
|
||||
- 全局变量:`vitest/globals`(describe、it、expect 等无需导入)
|
||||
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
|
||||
- `chrome.*` / `browser.*` API(storage、tabs、runtime、cookies 等)
|
||||
- `react-i18next`(返回 key 作为翻译)
|
||||
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
|
||||
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
|
||||
|
||||
### 国际化(i18n)
|
||||
|
||||
- 命名空间:`common`(默认)、`features`
|
||||
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||
- 语言:`zh`(默认)、`en`
|
||||
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
|
||||
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
|
||||
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
|
||||
|
||||
### WXT 生成文件
|
||||
|
||||
- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
|
||||
- 生产构建输出到 `.output/` 目录
|
||||
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
|
||||
@@ -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,6 @@ stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
.vitest
|
||||
.claude
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -25,3 +24,11 @@ stats-*.json
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.trae/*
|
||||
.workbuddy/*
|
||||
.qoder/*
|
||||
dev/*
|
||||
|
||||
docs/*
|
||||
!docs/VISUAL_STYLE_GUIDE.md
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
npx lint-staged
|
||||
# 后台运行类型检查,结果输出到 stderr 但不阻塞提交
|
||||
npx tsc --noEmit &
|
||||
|
||||
# 前台运行 lint-staged(自动修复 + 格式化)
|
||||
npx lint-staged
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
||||
# 类型检查
|
||||
npx tsc --noEmit
|
||||
|
||||
# ESLint 严格检查(不允许 warning)
|
||||
npx eslint . --max-warnings=0
|
||||
@@ -1,143 +1,153 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
||||
- `npm run zip` - 打包 Chrome 扩展
|
||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
||||
- `npm run lint` - 运行 ESLint 检查
|
||||
|
||||
### 测试相关
|
||||
|
||||
- `npm run test` - 运行所有测试(单次执行)
|
||||
- `npm run test:watch` - 运行测试并监听文件变化
|
||||
- `npm run test:coverage` - 运行测试并生成覆盖率报告
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx vitest run components/__tests__/CopyButton.test.tsx
|
||||
npm run dev # Chrome 开发模式 (HMR)
|
||||
npm run dev:firefox # Firefox 开发模式
|
||||
npm run build # Chrome 生产构建
|
||||
npm run build:firefox # Firefox 生产构建
|
||||
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
|
||||
npm run zip:firefox # 打包 Firefox 扩展
|
||||
npm run lint # ESLint (--max-warnings=0)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test # vitest run (单次执行)
|
||||
npm run test:watch # vitest 监视模式
|
||||
npm run test:coverage # 带覆盖率的测试
|
||||
```
|
||||
|
||||
**测试技术栈:**
|
||||
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||
|
||||
- Vitest - 测试框架
|
||||
- @testing-library/react - React 组件测试
|
||||
- @testing-library/user-event v13 - 用户交互模拟(注意:v13 不支持 setup(),使用 fireEvent)
|
||||
- jsdom - 浏览器环境模拟
|
||||
## 验证流程
|
||||
|
||||
### 依赖与准备
|
||||
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败):
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
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`
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI (MUI)
|
||||
- **状态管理**: React Hooks
|
||||
- **路由**: React Router DOM
|
||||
## WXT 生成文件
|
||||
|
||||
### 目录结构
|
||||
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
|
||||
- 生产构建输出到 `.output/` 目录。
|
||||
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── components/ # 可复用 UI 组件
|
||||
│ ├── CopyButton.tsx # 复制按钮组件
|
||||
│ ├── DatetimeToTimestamp.tsx # 日期转时间戳组件
|
||||
│ ├── Navbar.tsx # 导航栏组件
|
||||
│ ├── RoutePersistence.tsx # 路由持久化组件
|
||||
│ ├── TimestampExecution.tsx # 时间戳执行组件
|
||||
│ └── TimestampToDatetime.tsx # 时间戳转日期组件
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── background.ts # 后台脚本(主进程)
|
||||
│ ├── content.ts # 内容脚本(注入到页面)
|
||||
│ └── popup/ # 扩展弹窗界面
|
||||
│ ├── App.tsx # 弹窗主应用
|
||||
│ ├── main.tsx # 弹窗入口
|
||||
│ └── pages/ # 弹窗页面
|
||||
│ ├── TestPage.tsx # 测试页面
|
||||
│ └── TimestampPage.tsx # 时间戳工具页面
|
||||
├── utils/ # 工具函数
|
||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
||||
│ ├── dayjs.ts # 日期处理工具
|
||||
│ └── messages.tsx # 消息通信工具
|
||||
├── types/ # 类型定义
|
||||
│ └── storage.d.ts # 存储相关类型
|
||||
src/ # 源代码根目录
|
||||
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
|
||||
pages/ # 功能页面组件 (懒加载)
|
||||
components/ # 可复用 UI 组件
|
||||
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||
providers/ # React Context (Router, Theme 等)
|
||||
hooks/ # 自定义 React Hooks
|
||||
utils/ # 工具函数与服务抽象
|
||||
types/ # TypeScript 类型声明
|
||||
lib/ # 通用工具函数(cn、utils)
|
||||
public/ # 静态资源(图标、_locales 等)
|
||||
```
|
||||
|
||||
### 核心功能实现
|
||||
### 页面组件模式
|
||||
|
||||
#### 1. 时间戳转换工具
|
||||
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
|
||||
|
||||
- 位置: `components/` 目录下的时间戳相关组件
|
||||
- 依赖: dayjs 库进行日期处理
|
||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式
|
||||
|
||||
#### 2. 通信系统
|
||||
|
||||
- 位置: `utils/messages.tsx`
|
||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
|
||||
|
||||
#### 3. 数据存储
|
||||
|
||||
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
|
||||
|
||||
### 关键配置文件
|
||||
|
||||
#### wxt.config.ts
|
||||
|
||||
- 配置 WXT 框架参数
|
||||
- 启用 React 模块
|
||||
- 配置浏览器扩展权限
|
||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
||||
|
||||
#### manifest 权限
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage', // 存储权限
|
||||
'unlimitedStorage', // 无限制存储
|
||||
'clipboardWrite', // 剪贴板写入
|
||||
'activeTab', // 当前标签页
|
||||
'scripting', // 脚本注入
|
||||
'tabs', // 标签页管理
|
||||
'debugger', // 调试器
|
||||
],
|
||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
||||
```
|
||||
src/pages/FeatureName/
|
||||
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局)
|
||||
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||
├── constants.ts # 常量定义(可选,≥3 个常量时创建)
|
||||
└── __tests__/
|
||||
└── index.test.tsx
|
||||
```
|
||||
|
||||
## 开发注意事项
|
||||
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
|
||||
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||
- 子组件可以独立调用 `useI18n` 等全局 Hook
|
||||
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
||||
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
||||
|
||||
### 扩展入口点
|
||||
## 关键架构决策
|
||||
|
||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
||||
**路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_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` 做快照以消除首屏闪烁。
|
||||
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
|
||||
|
||||
- 支持 Chrome 和 Firefox 浏览器
|
||||
- 使用 WXT 框架抽象浏览器差异
|
||||
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`。
|
||||
|
||||
### 代码质量
|
||||
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||
|
||||
- 使用 ESLint 进行代码检查
|
||||
- Husky 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范
|
||||
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||
|
||||
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 环境: jsdom
|
||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||
- `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
||||
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||
|
||||
## i18n (chrome.i18n)
|
||||
|
||||
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
|
||||
|
||||
- **翻译文件**: `public/_locales/{zh,en}/messages.json`(Chrome 扩展标准格式)
|
||||
- **默认语言**: `zh`(在 `wxt.config.ts` 的 `manifest.default_locale` 中配置)
|
||||
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'`
|
||||
- **翻译键格式**:
|
||||
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title`
|
||||
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search`
|
||||
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key`、`features_key`
|
||||
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })`
|
||||
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
|
||||
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
|
||||
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
|
||||
|
||||
## 新功能开发清单
|
||||
|
||||
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
||||
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
||||
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
||||
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译
|
||||
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||
- `constants.ts` — 常量(可选)
|
||||
4. 在 `public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译
|
||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||
6. 添加对应的单元测试
|
||||
|
||||
## 代码规范
|
||||
|
||||
- 禁止使用 `any` (测试文件除外)
|
||||
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
|
||||
- 图标: 使用 `lucide-react` 图标库
|
||||
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||
|
||||
## 关键外部库(非显而易见的)
|
||||
|
||||
- `@webext-core/messaging` — 扩展消息通信
|
||||
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
|
||||
- `qrious` + `qr-scanner` — 二维码生成与解析
|
||||
- `dayjs` — 日期处理(时间戳转换)
|
||||
- `sonner` — Toast 通知(替代传统 snackbar)
|
||||
|
||||
@@ -1,171 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
||||
- `npm run zip` - 打包 Chrome 扩展
|
||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
||||
- `npm run lint` - 运行 ESLint 检查
|
||||
|
||||
### 依赖与准备
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
|
||||
### CI/CD
|
||||
|
||||
- GitHub Actions 配置: `.github/workflows/node.js.yml`
|
||||
- 在 main 分支推送或 PR 时触发
|
||||
- 使用 Node.js 22.x 运行 build
|
||||
- 测试命令当前被注释(项目暂无测试)
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI (MUI)
|
||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||
- **通信**: @webext-core/messaging
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
|
||||
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
|
||||
│ ├── popup/ # 扩展弹窗界面
|
||||
│ │ ├── App.tsx # 弹窗主应用
|
||||
│ │ ├── main.tsx # 弹窗入口
|
||||
│ │ ├── index.html # 弹窗 HTML
|
||||
│ │ └── pages/ # 弹窗页面
|
||||
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
|
||||
│ └── options/ # 选项页面(当前为静态 HTML)
|
||||
│ └── index.html # 选项页 HTML
|
||||
├── utils/ # 工具函数
|
||||
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
|
||||
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
|
||||
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging)
|
||||
├── types/ # 类型定义
|
||||
│ └── storage.d.ts # StorageSchema 类型定义
|
||||
├── constants/ # 常量定义(当前为空)
|
||||
└── public/ # 静态资源
|
||||
```
|
||||
|
||||
### 核心功能
|
||||
|
||||
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
|
||||
|
||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
||||
- 时间戳 → 日期时间转换
|
||||
- 日期时间 → 时间戳转换
|
||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
||||
- 一键复制功能
|
||||
- 输入验证和错误提示
|
||||
|
||||
### 扩展入口点
|
||||
|
||||
- **后台脚本** (`entrypoints/background.ts`):
|
||||
- 监听扩展安装/更新事件
|
||||
- 自动向所有有效标签页注入内容脚本
|
||||
- 过滤受限协议(chrome://, about:// 等)
|
||||
|
||||
- **内容脚本** (`entrypoints/content.ts`):
|
||||
- 匹配所有 URL (`<all_urls>`)
|
||||
- 在文档开始时运行
|
||||
- 当前为占位符,无实际逻辑
|
||||
|
||||
- **弹窗** (`entrypoints/popup/`):
|
||||
- 主入口显示 TimestampPage
|
||||
- 提供时间戳转换的完整功能
|
||||
|
||||
- **选项页** (`entrypoints/options/`):
|
||||
- 当前为静态 HTML 页面
|
||||
- 可扩展为设置界面
|
||||
|
||||
### 数据存储
|
||||
|
||||
使用 Chrome Storage API 进行持久化存储:
|
||||
|
||||
- 类型安全的封装 (`utils/chromeStorage.ts`)
|
||||
- 基于接口定义的 Schema (`types/storage.d.ts`)
|
||||
- 当前支持的存储键:
|
||||
- `app/lastRoute`: 上次访问的路由
|
||||
- `app/theme`: 主题设置
|
||||
|
||||
### 消息通信
|
||||
|
||||
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
|
||||
|
||||
- 定义在 `utils/messages.tsx`
|
||||
- 当前 ProtocolMap 为空(预留接口)
|
||||
|
||||
### 关键配置文件
|
||||
|
||||
#### wxt.config.ts
|
||||
|
||||
- 启用 React 模块 (`@wxt-dev/module-react`)
|
||||
- 配置 manifest 权限和 host_permissions
|
||||
- 使用 Terser 压缩(强制 ASCII 编码)
|
||||
- 配置图标和选项页
|
||||
|
||||
#### manifest 权限
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage', // Chrome Storage
|
||||
'unlimitedStorage', // 无限制存储
|
||||
'clipboardWrite', // 剪贴板写入(复制功能)
|
||||
'activeTab', // 当前标签页访问
|
||||
'scripting', // 脚本注入
|
||||
'tabs', // 标签页管理
|
||||
'debugger', // 调试器权限
|
||||
],
|
||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
||||
```
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
- 支持 Chrome 和 Firefox 浏览器
|
||||
- 使用 WXT 框架抽象浏览器差异
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 使用 ESLint 进行代码检查(零警告)
|
||||
- Husky 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier)
|
||||
- Prettier 用于代码格式化
|
||||
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
|
||||
|
||||
### TypeScript 配置
|
||||
|
||||
- 严格模式开启(`strict: true`)
|
||||
- `noImplicitAny` 设置为 `false`(允许隐式 any)
|
||||
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`)
|
||||
- 模块解析模式:Bundler
|
||||
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
|
||||
|
||||
### 项目历史
|
||||
|
||||
近期重构(根据 git 历史):
|
||||
|
||||
- 移除了录制回放功能
|
||||
- 移除了测试页面
|
||||
- 精简为单页面时间戳工具
|
||||
- 将 storage 工具类重命名为 storageUtil
|
||||
@AGENT.md
|
||||
|
||||
@@ -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,153 @@
|
||||
# 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 编码与实时预览.
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit)
|
||||
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI
|
||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||
- **国际化**: i18next + react-i18next
|
||||
- **通信**: @webext-core/messaging
|
||||
- **存储**: Chrome Storage API (类型安全封装)
|
||||
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||
- **测试**: Vitest + Testing Library
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── popup/ # 扩展弹窗界面(时间戳转换页面)
|
||||
│ ├── options/ # 选项页面
|
||||
│ ├── background.ts # 后台脚本
|
||||
│ └── content.ts # 内容脚本
|
||||
├── utils/ # 工具函数(存储、日期处理、消息通信)
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── public/ # 静态资源
|
||||
├── wxt.config.ts # WXT 配置文件
|
||||
├── package.json # 项目依赖和脚本
|
||||
└── README.md # 项目说明文档
|
||||
```text
|
||||
├── src/ # 源代码根目录
|
||||
│ ├── components/ # 可复用 React 组件
|
||||
│ ├── config/ # 应用配置
|
||||
│ │ └── features.tsx # 功能定义与路由映射
|
||||
│ ├── entrypoints/ # 扩展程序入口点
|
||||
│ │ ├── popup/ # 点击图标弹出的主界面
|
||||
│ │ ├── sidepanel/ # 浏览器侧边栏集成
|
||||
│ │ ├── background.ts # 后台 Service Worker
|
||||
│ │ └── content.ts # 网页注入脚本
|
||||
│ ├── pages/ # 各功能模块的页面组件
|
||||
│ ├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||
│ ├── hooks/ # 自定义 React Hooks
|
||||
│ ├── utils/ # 工具函数与服务抽象
|
||||
│ ├── types/ # TypeScript 类型声明
|
||||
│ └── lib/ # 通用工具函数 (cn, utils 等)
|
||||
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||
├── wxt.config.ts # WXT 框架核心配置
|
||||
└── package.json # 项目元数据与依赖管理
|
||||
```
|
||||
|
||||
## 开发环境要求
|
||||
## 开发与部署
|
||||
|
||||
- Node.js >= 18
|
||||
- npm 或 yarn
|
||||
### 开发环境要求
|
||||
|
||||
## 安装与运行
|
||||
- Node.js >= 18.x
|
||||
- npm 或 pnpm
|
||||
|
||||
### 1. 安装依赖
|
||||
### 常用命令
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
| 命令 | 说明 |
|
||||
| ----------------------- | --------------------------------- |
|
||||
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||
| `npm run build` | 构建 Chrome 生产版本 |
|
||||
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||
| `npm run test` | 运行单元测试 |
|
||||
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||
|
||||
### 2. 开发模式
|
||||
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run dev
|
||||
### 自动化流程
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run dev:firefox
|
||||
```
|
||||
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||
|
||||
### 3. 构建生产版本
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run build
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run build:firefox
|
||||
```
|
||||
|
||||
### 4. 打包分发
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run zip
|
||||
|
||||
# Firefox
|
||||
npm run zip:firefox
|
||||
```
|
||||
|
||||
### 5. 其他命令
|
||||
|
||||
```bash
|
||||
npm run compile # TypeScript 类型检查
|
||||
npm run lint # ESLint 代码检查
|
||||
```
|
||||
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||
|
||||
## 权限说明
|
||||
|
||||
扩展请求以下权限:
|
||||
本扩展根据功能需要申请了以下权限:
|
||||
|
||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
||||
- `debugger` - 调试器权限
|
||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
||||
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||
- `cookies`: 管理和清理网站 Cookie.
|
||||
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||
- `clipboardWrite`: 提供一键复制功能.
|
||||
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||
|
||||
## 主要依赖
|
||||
## 浏览器支持
|
||||
|
||||
- `react`, `react-dom` - 前端框架
|
||||
- `@mui/material` - UI 组件库
|
||||
- `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
|
||||
- Chrome (及其它 Chromium 内核浏览器)
|
||||
- Firefox
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目为私有项目 (private: true),仅供内部使用。
|
||||
基于 [MIT License](LICENSE) 开源.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
# 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 { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n('newTool');
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] 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">{t('newTool:title')}</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. 测试用户偏好持久化
|
||||
|
||||
## 后续优化
|
||||
|
||||
- 显示清理前的存储使用情况
|
||||
- 支持批量清理多个标签页
|
||||
- 支持自定义域名清理
|
||||
@@ -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,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 tseslint from 'typescript-eslint';
|
||||
import * as reactHooks from 'eslint-plugin-react-hooks';
|
||||
import * as reactPlugin from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactPlugin from 'eslint-plugin-react';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', '.wxt', 'node_modules', 'eslint.config.ts', '**/*.test.tsx', '**/*.test.ts', '**/__tests__/**'] },
|
||||
{
|
||||
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
{
|
||||
files: [
|
||||
'hooks/**/*.{ts,tsx}',
|
||||
'entrypoints/**/*.{ts,tsx}',
|
||||
'pages/**/*.{ts,tsx}',
|
||||
'utils/**/*.{ts,tsx}',
|
||||
'components/**/*.{ts,tsx}',
|
||||
'services/**/*.{ts,tsx}',
|
||||
],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
react: reactPlugin as any, // 现在这里就算写 TS 语法也没事了,因为文件被忽略了
|
||||
'react-hooks': reactHooks as any,
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs['jsx-runtime'].rules,
|
||||
|
||||
'react/prop-types': 'off',
|
||||
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
/* global process */
|
||||
|
||||
const isCI = process.env.CI === 'true';
|
||||
|
||||
export default {
|
||||
// 对于代码文件:
|
||||
'*.{ts,tsx,js,jsx}': [
|
||||
// 1. ESLint: 依然检查具体文件,拦截未使用变量
|
||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
||||
|
||||
// 2. TypeScript: 使用函数形式
|
||||
// 关键!这就告诉 lint-staged:“不要把文件名传给 tsc,直接运行这个命令就好”
|
||||
// 这样 tsc 就会去读取 tsconfig.json,并正确排除 eslint.config.ts
|
||||
() => 'tsc --noEmit --skipLibCheck',
|
||||
'*.{ts,tsx,js,jsx,mjs}': [
|
||||
'eslint --fix --no-warn-ignored',
|
||||
...(isCI ? ['eslint --max-warnings=0'] : []),
|
||||
'prettier --write',
|
||||
],
|
||||
|
||||
// 对于其他文件:
|
||||
'*.{json,css,scss,md}': ['prettier --write'],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "wxt-react-starter",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"name": "testing-tools",
|
||||
"description": "A browser extension providing useful testing tools including timestamp conversion and storage management",
|
||||
"private": false,
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
@@ -11,40 +12,65 @@
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"zip": "wxt zip",
|
||||
"zip:firefox": "wxt zip -b firefox",
|
||||
"compile": "tsc --noEmit",
|
||||
"postinstall": "wxt prepare",
|
||||
"prepare": "husky",
|
||||
"lint": "eslint . --max-warnings=0"
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.8",
|
||||
"@mui/material": "^7.3.8",
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"@webext-core/messaging": "^3.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qr-scanner": "^1.4.2",
|
||||
"qrious": "^4.0.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chrome": "^0.1.36",
|
||||
"@types/react": "^19.2.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/chrome": "^0.1.42",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/webextension-polyfill": "^0.12.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||
"@typescript-eslint/parser": "^8.54.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@wxt-dev/module-react": "^1.1.5",
|
||||
"@types/webextension-polyfill": "^0.12.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.2.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.8.1",
|
||||
"terser": "^5.46.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"terser": "^5.47.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"wxt": "^0.20.6"
|
||||
"typescript-eslint": "^8.59.4",
|
||||
"vitest": "^4.1.7",
|
||||
"wxt": "^0.20.26"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import tailwindcss from 'tailwindcss';
|
||||
import autoprefixer from 'autoprefixer';
|
||||
|
||||
export default {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
};
|
||||
@@ -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.svg` | WXT 框架标志 SVG 图标 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
||||
- 图标格式推荐使用 PNG,确保透明背景
|
||||
@@ -0,0 +1,924 @@
|
||||
{
|
||||
"buttons_copy": {
|
||||
"message": "复制",
|
||||
"description": "Translation key: buttons_copy"
|
||||
},
|
||||
"buttons_clear": {
|
||||
"message": "清理",
|
||||
"description": "Translation key: buttons_clear"
|
||||
},
|
||||
|
||||
"messages_copySuccess": {
|
||||
"message": "已复制到剪贴板",
|
||||
"description": "Translation key: messages_copySuccess"
|
||||
},
|
||||
"messages_copyError": {
|
||||
"message": "复制失败",
|
||||
"description": "Translation key: messages_copyError"
|
||||
},
|
||||
"messages_copyEmpty": {
|
||||
"message": "无内容可复制",
|
||||
"description": "Translation key: messages_copyEmpty"
|
||||
},
|
||||
"errorBoundary_title": {
|
||||
"message": "糟糕,出了点问题",
|
||||
"description": "Translation key: errorBoundary_title"
|
||||
},
|
||||
"errorBoundary_description": {
|
||||
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||
"description": "Translation key: errorBoundary_description"
|
||||
},
|
||||
"errorBoundary_refresh": {
|
||||
"message": "刷新应用",
|
||||
"description": "Translation key: errorBoundary_refresh"
|
||||
},
|
||||
"errorBoundary_retry": {
|
||||
"message": "重新尝试",
|
||||
"description": "Translation key: errorBoundary_retry"
|
||||
},
|
||||
"pageErrorBoundary_title": {
|
||||
"message": "该功能运行异常",
|
||||
"description": "Translation key: pageErrorBoundary_title"
|
||||
},
|
||||
"pageErrorBoundary_description": {
|
||||
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
|
||||
"description": "Translation key: pageErrorBoundary_description"
|
||||
},
|
||||
"router_notFound": {
|
||||
"message": "页面未找到",
|
||||
"description": "Translation key: router_notFound"
|
||||
},
|
||||
"router_notFoundDescription": {
|
||||
"message": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。",
|
||||
"description": "Translation key: router_notFoundDescription"
|
||||
},
|
||||
"textInputArea_clear": {
|
||||
"message": "清空",
|
||||
"description": "Translation key: textInputArea_clear"
|
||||
},
|
||||
"textInputArea_copyContent": {
|
||||
"message": "复制内容",
|
||||
"description": "Translation key: textInputArea_copyContent"
|
||||
},
|
||||
"textInputArea_cleared": {
|
||||
"message": "已清空",
|
||||
"description": "Translation key: textInputArea_cleared"
|
||||
},
|
||||
"textInputArea_placeholder": {
|
||||
"message": "请输入文本",
|
||||
"description": "Translation key: textInputArea_placeholder"
|
||||
},
|
||||
"dashboard_title": {
|
||||
"message": "仪表盘",
|
||||
"description": "Translation key: dashboard_title"
|
||||
},
|
||||
"dashboard_searchPlaceholder": {
|
||||
"message": "搜索工具...",
|
||||
"description": "Translation key: dashboard_searchPlaceholder"
|
||||
},
|
||||
"dashboard_recentlyUsed": {
|
||||
"message": "最近使用",
|
||||
"description": "Translation key: dashboard_recentlyUsed"
|
||||
},
|
||||
"dashboard_allTools": {
|
||||
"message": "全部工具",
|
||||
"description": "Translation key: dashboard_allTools"
|
||||
},
|
||||
"timestamp_title": {
|
||||
"message": "时间戳",
|
||||
"description": "Translation key: timestamp_title"
|
||||
},
|
||||
"timestamp_description": {
|
||||
"message": "Unix 毫秒数转换与格式化",
|
||||
"description": "Translation key: timestamp_description"
|
||||
},
|
||||
"storageCleaner_title": {
|
||||
"message": "存储清理",
|
||||
"description": "Translation key: storageCleaner_title"
|
||||
},
|
||||
"storageCleaner_description": {
|
||||
"message": "清理缓存、Cookies 及本地存储",
|
||||
"description": "Translation key: storageCleaner_description"
|
||||
},
|
||||
"qrCode_title": {
|
||||
"message": "二维码工具",
|
||||
"description": "Translation key: qrCode_title"
|
||||
},
|
||||
"qrCode_description": {
|
||||
"message": "生成当前选中的 URL 的二维码",
|
||||
"description": "Translation key: qrCode_description"
|
||||
},
|
||||
"textStatistics_title": {
|
||||
"message": "文本统计",
|
||||
"description": "Translation key: textStatistics_title"
|
||||
},
|
||||
"textStatistics_description": {
|
||||
"message": "实时分析文本字符、单词及字节",
|
||||
"description": "Translation key: textStatistics_description"
|
||||
},
|
||||
"jwt_title": {
|
||||
"message": "JWT 解析",
|
||||
"description": "Translation key: jwt_title"
|
||||
},
|
||||
"jwt_description": {
|
||||
"message": "JSON Web Token 解码与查看",
|
||||
"description": "Translation key: jwt_description"
|
||||
},
|
||||
"jsonDiff_title": {
|
||||
"message": "JSON 工具",
|
||||
"description": "Translation key: jsonDiff_title"
|
||||
},
|
||||
"jsonDiff_description": {
|
||||
"message": "差异比较、格式化、YAML/TOML 转换及压缩",
|
||||
"description": "Translation key: jsonDiff_description"
|
||||
},
|
||||
"base64Converter_title": {
|
||||
"message": "Base64 转换器",
|
||||
"description": "Translation key: base64Converter_title"
|
||||
},
|
||||
"base64Converter_description": {
|
||||
"message": "文本、文件与图像的 Base64 编码转换",
|
||||
"description": "Translation key: base64Converter_description"
|
||||
},
|
||||
"rightClickRestorer_title": {
|
||||
"message": "右键恢复",
|
||||
"description": "Translation key: rightClickRestorer_title"
|
||||
},
|
||||
"rightClickRestorer_description": {
|
||||
"message": "检测并恢复被网站禁用的浏览器右键菜单",
|
||||
"description": "Translation key: rightClickRestorer_description"
|
||||
},
|
||||
"base64Converter_pageTitle": {
|
||||
"message": "Base64 转换器",
|
||||
"description": "Translation key: base64Converter_pageTitle"
|
||||
},
|
||||
"base64Converter_textMode": {
|
||||
"message": "文本",
|
||||
"description": "Translation key: base64Converter_textMode"
|
||||
},
|
||||
"base64Converter_fileMode": {
|
||||
"message": "文件",
|
||||
"description": "Translation key: base64Converter_fileMode"
|
||||
},
|
||||
"base64Converter_imageMode": {
|
||||
"message": "图像",
|
||||
"description": "Translation key: base64Converter_imageMode"
|
||||
},
|
||||
"base64Converter_encode": {
|
||||
"message": "编码",
|
||||
"description": "Translation key: base64Converter_encode"
|
||||
},
|
||||
"base64Converter_decode": {
|
||||
"message": "解码",
|
||||
"description": "Translation key: base64Converter_decode"
|
||||
},
|
||||
"base64Converter_clear": {
|
||||
"message": "清空",
|
||||
"description": "Translation key: base64Converter_clear"
|
||||
},
|
||||
"base64Converter_textInputPlaceholder": {
|
||||
"message": "输入需要编码为 Base64 的文本...",
|
||||
"description": "Translation key: base64Converter_textInputPlaceholder"
|
||||
},
|
||||
"base64Converter_base64InputPlaceholder": {
|
||||
"message": "输入需要解码的 Base64 字符串...",
|
||||
"description": "Translation key: base64Converter_base64InputPlaceholder"
|
||||
},
|
||||
"base64Converter_base64Output": {
|
||||
"message": "Base64 编码结果",
|
||||
"description": "Translation key: base64Converter_base64Output"
|
||||
},
|
||||
"base64Converter_textOutput": {
|
||||
"message": "解码文本结果",
|
||||
"description": "Translation key: base64Converter_textOutput"
|
||||
},
|
||||
"base64Converter_copyRaw": {
|
||||
"message": "复制纯 Base64",
|
||||
"description": "Translation key: base64Converter_copyRaw"
|
||||
},
|
||||
"base64Converter_copyDataUri": {
|
||||
"message": "复制 Data URI",
|
||||
"description": "Translation key: base64Converter_copyDataUri"
|
||||
},
|
||||
"base64Converter_clickOrDropToFile": {
|
||||
"message": "点击或拖拽文件到此处",
|
||||
"description": "Translation key: base64Converter_clickOrDropToFile"
|
||||
},
|
||||
"base64Converter_clickOrDropToImage": {
|
||||
"message": "点击或拖拽图像到此处",
|
||||
"description": "Translation key: base64Converter_clickOrDropToImage"
|
||||
},
|
||||
"base64Converter_clickOrDropToReplace": {
|
||||
"message": "点击或拖拽以替换文件",
|
||||
"description": "Translation key: base64Converter_clickOrDropToReplace"
|
||||
},
|
||||
"base64Converter_maxFileSize": {
|
||||
"message": "最大文件大小:{{max}}",
|
||||
"description": "Translation key: base64Converter_maxFileSize"
|
||||
},
|
||||
"base64Converter_supportedFormats": {
|
||||
"message": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
|
||||
"description": "Translation key: base64Converter_supportedFormats"
|
||||
},
|
||||
"base64Converter_fileSizeExceeded": {
|
||||
"message": "文件大小超出限制(最大 {{max}})",
|
||||
"description": "Translation key: base64Converter_fileSizeExceeded"
|
||||
},
|
||||
"base64Converter_unsupportedImageType": {
|
||||
"message": "不支持的图像格式",
|
||||
"description": "Translation key: base64Converter_unsupportedImageType"
|
||||
},
|
||||
"base64Converter_conversionFailed": {
|
||||
"message": "转换失败",
|
||||
"description": "Translation key: base64Converter_conversionFailed"
|
||||
},
|
||||
"base64Converter_originalSize": {
|
||||
"message": "原始大小",
|
||||
"description": "Translation key: base64Converter_originalSize"
|
||||
},
|
||||
"base64Converter_encodedSize": {
|
||||
"message": "编码大小",
|
||||
"description": "Translation key: base64Converter_encodedSize"
|
||||
},
|
||||
"base64Converter_invalidBase64": {
|
||||
"message": "Base64 字符串无效",
|
||||
"description": "Translation key: base64Converter_invalidBase64"
|
||||
},
|
||||
"base64Converter_binaryDataDetected": {
|
||||
"message": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
|
||||
"description": "Translation key: base64Converter_binaryDataDetected"
|
||||
},
|
||||
"base64Converter_imageDataUriHint": {
|
||||
"message": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
|
||||
"description": "Translation key: base64Converter_imageDataUriHint"
|
||||
},
|
||||
"base64Converter_switchToImageMode": {
|
||||
"message": "切换到图像模式",
|
||||
"description": "Translation key: base64Converter_switchToImageMode"
|
||||
},
|
||||
"base64Converter_download": {
|
||||
"message": "下载",
|
||||
"description": "Translation key: base64Converter_download"
|
||||
},
|
||||
"base64Converter_decodedFileName": {
|
||||
"message": "解码后文件名",
|
||||
"description": "Translation key: base64Converter_decodedFileName"
|
||||
},
|
||||
"base64Converter_decodeBase64Placeholder": {
|
||||
"message": "输入需要解码的 Base64 或 data URI...",
|
||||
"description": "Translation key: base64Converter_decodeBase64Placeholder"
|
||||
},
|
||||
"base64Converter_decodedFileOutput": {
|
||||
"message": "解码文件",
|
||||
"description": "Translation key: base64Converter_decodedFileOutput"
|
||||
},
|
||||
"base64Converter_decodedImageOutput": {
|
||||
"message": "解码图像",
|
||||
"description": "Translation key: base64Converter_decodedImageOutput"
|
||||
},
|
||||
"base64Converter_inferredMimeType": {
|
||||
"message": "推断的 MIME 类型",
|
||||
"description": "Translation key: base64Converter_inferredMimeType"
|
||||
},
|
||||
"base64Converter_decodedSize": {
|
||||
"message": "解码大小",
|
||||
"description": "Translation key: base64Converter_decodedSize"
|
||||
},
|
||||
"jsonDiff_pageTitle": {
|
||||
"message": "JSON 差异比较",
|
||||
"description": "Translation key: jsonDiff_pageTitle"
|
||||
},
|
||||
"jsonDiff_leftPlaceholder": {
|
||||
"message": "输入原始 JSON...",
|
||||
"description": "Translation key: jsonDiff_leftPlaceholder"
|
||||
},
|
||||
"jsonDiff_rightPlaceholder": {
|
||||
"message": "输入目标 JSON...",
|
||||
"description": "Translation key: jsonDiff_rightPlaceholder"
|
||||
},
|
||||
"jsonDiff_leftLabel": {
|
||||
"message": "原始 JSON",
|
||||
"description": "Translation key: jsonDiff_leftLabel"
|
||||
},
|
||||
"jsonDiff_rightLabel": {
|
||||
"message": "目标 JSON",
|
||||
"description": "Translation key: jsonDiff_rightLabel"
|
||||
},
|
||||
"jsonDiff_sideBySideMode": {
|
||||
"message": "并排",
|
||||
"description": "Translation key: jsonDiff_sideBySideMode"
|
||||
},
|
||||
"jsonDiff_unifiedMode": {
|
||||
"message": "统一",
|
||||
"description": "Translation key: jsonDiff_unifiedMode"
|
||||
},
|
||||
"jsonDiff_previousDiff": {
|
||||
"message": "上一个",
|
||||
"description": "Translation key: jsonDiff_previousDiff"
|
||||
},
|
||||
"jsonDiff_nextDiff": {
|
||||
"message": "下一个",
|
||||
"description": "Translation key: jsonDiff_nextDiff"
|
||||
},
|
||||
"jsonDiff_noDiffs": {
|
||||
"message": "无差异",
|
||||
"description": "Translation key: jsonDiff_noDiffs"
|
||||
},
|
||||
"jsonDiff_invalidJson": {
|
||||
"message": "无效的 JSON 格式",
|
||||
"description": "Translation key: jsonDiff_invalidJson"
|
||||
},
|
||||
"jsonDiff_emptyHint": {
|
||||
"message": "输入两侧 JSON 后点击比较",
|
||||
"description": "Translation key: jsonDiff_emptyHint"
|
||||
},
|
||||
"jsonDiff_fixErrorHint": {
|
||||
"message": "请修正上方 JSON 的语法错误以开启实时流式比对",
|
||||
"description": "Translation key: jsonDiff_fixErrorHint"
|
||||
},
|
||||
"jsonDiff_added": {
|
||||
"message": "新增",
|
||||
"description": "Translation key: jsonDiff_added"
|
||||
},
|
||||
"jsonDiff_removed": {
|
||||
"message": "删除",
|
||||
"description": "Translation key: jsonDiff_removed"
|
||||
},
|
||||
"jsonDiff_modified": {
|
||||
"message": "修改",
|
||||
"description": "Translation key: jsonDiff_modified"
|
||||
},
|
||||
"jsonFormat_inputPlaceholder": {
|
||||
"message": "输入需要格式化的 JSON...",
|
||||
"description": "Translation key: jsonFormat_inputPlaceholder"
|
||||
},
|
||||
"jsonFormat_sortKeys": {
|
||||
"message": "键名排序",
|
||||
"description": "Translation key: jsonFormat_sortKeys"
|
||||
},
|
||||
"jsonFormat_indentSize": {
|
||||
"message": "缩进",
|
||||
"description": "Translation key: jsonFormat_indentSize"
|
||||
},
|
||||
"jsonFormat_outputLabel": {
|
||||
"message": "格式化结果",
|
||||
"description": "Translation key: jsonFormat_outputLabel"
|
||||
},
|
||||
"jsonFormat_invalidJson": {
|
||||
"message": "无效的 JSON 格式",
|
||||
"description": "Translation key: jsonFormat_invalidJson"
|
||||
},
|
||||
"jsonFormat_emptyHint": {
|
||||
"message": "输入 JSON 后点击格式化",
|
||||
"description": "Translation key: jsonFormat_emptyHint"
|
||||
},
|
||||
"jsonFormat_fixErrorHint": {
|
||||
"message": "请修正上方 JSON 的语法错误以开启实时流式格式化",
|
||||
"description": "Translation key: jsonFormat_fixErrorHint"
|
||||
},
|
||||
"jsonFormat_originalSize": {
|
||||
"message": "原始大小",
|
||||
"description": "Translation key: jsonFormat_originalSize"
|
||||
},
|
||||
"jsonFormat_formattedSize": {
|
||||
"message": "格式化后大小",
|
||||
"description": "Translation key: jsonFormat_formattedSize"
|
||||
},
|
||||
"jsonFormat_diffMode": {
|
||||
"message": "差异比较",
|
||||
"description": "Translation key: jsonFormat_diffMode"
|
||||
},
|
||||
"jsonFormat_formatMode": {
|
||||
"message": "格式化",
|
||||
"description": "Translation key: jsonFormat_formatMode"
|
||||
},
|
||||
"jsonFormat_yamlMode": {
|
||||
"message": "YAML",
|
||||
"description": "Translation key: jsonFormat_yamlMode"
|
||||
},
|
||||
"jsonFormat_tomlMode": {
|
||||
"message": "TOML",
|
||||
"description": "Translation key: jsonFormat_tomlMode"
|
||||
},
|
||||
"jsonFormat_minifyMode": {
|
||||
"message": "压缩",
|
||||
"description": "Translation key: jsonFormat_minifyMode"
|
||||
},
|
||||
"jsonFormat_yamlModeInputPlaceholder": {
|
||||
"message": "输入需要转换的 JSON...",
|
||||
"description": "Translation key: jsonFormat_yamlModeInputPlaceholder"
|
||||
},
|
||||
"jsonFormat_yamlModeOutputLabel": {
|
||||
"message": "YAML 结果",
|
||||
"description": "Translation key: jsonFormat_yamlModeOutputLabel"
|
||||
},
|
||||
"jsonFormat_yamlModeEmptyHint": {
|
||||
"message": "输入 JSON 后点击转换",
|
||||
"description": "Translation key: jsonFormat_yamlModeEmptyHint"
|
||||
},
|
||||
"jsonFormat_tomlModeInputPlaceholder": {
|
||||
"message": "输入需要转换的 JSON...",
|
||||
"description": "Translation key: jsonFormat_tomlModeInputPlaceholder"
|
||||
},
|
||||
"jsonFormat_tomlModeOutputLabel": {
|
||||
"message": "TOML 结果",
|
||||
"description": "Translation key: jsonFormat_tomlModeOutputLabel"
|
||||
},
|
||||
"jsonFormat_tomlModeEmptyHint": {
|
||||
"message": "输入 JSON 后点击转换",
|
||||
"description": "Translation key: jsonFormat_tomlModeEmptyHint"
|
||||
},
|
||||
"jsonFormat_minifyModeInputPlaceholder": {
|
||||
"message": "输入需要压缩的 JSON...",
|
||||
"description": "Translation key: jsonFormat_minifyModeInputPlaceholder"
|
||||
},
|
||||
"jsonFormat_minifyModeOutputLabel": {
|
||||
"message": "压缩结果",
|
||||
"description": "Translation key: jsonFormat_minifyModeOutputLabel"
|
||||
},
|
||||
"jsonFormat_minifyModeEmptyHint": {
|
||||
"message": "输入 JSON 后点击压缩",
|
||||
"description": "Translation key: jsonFormat_minifyModeEmptyHint"
|
||||
},
|
||||
"jwt_pageTitle": {
|
||||
"message": "JWT 解析",
|
||||
"description": "Translation key: jwt_pageTitle"
|
||||
},
|
||||
"jwt_placeholder": {
|
||||
"message": "在此粘贴 JWT 令牌 (Encoded JWT)...",
|
||||
"description": "Translation key: jwt_placeholder"
|
||||
},
|
||||
"jwt_headerTitle": {
|
||||
"message": "HEADER: 算法 & 令牌类型",
|
||||
"description": "Translation key: jwt_headerTitle"
|
||||
},
|
||||
"jwt_payloadTitle": {
|
||||
"message": "PAYLOAD: 数据",
|
||||
"description": "Translation key: jwt_payloadTitle"
|
||||
},
|
||||
"jwt_signatureTitle": {
|
||||
"message": "签名",
|
||||
"description": "Translation key: jwt_signatureTitle"
|
||||
},
|
||||
"jwt_noSignature": {
|
||||
"message": "无签名",
|
||||
"description": "Translation key: jwt_noSignature"
|
||||
},
|
||||
"jwt_invalidFormat": {
|
||||
"message": "无法解析",
|
||||
"description": "Translation key: jwt_invalidFormat"
|
||||
},
|
||||
"jwt_errors_invalidBase64String": {
|
||||
"message": "无效的 Base64URL 字符串",
|
||||
"description": "Translation key: jwt_errors_invalidBase64String"
|
||||
},
|
||||
"jwt_errors_failedToDecode": {
|
||||
"message": "Base64URL 解码失败:",
|
||||
"description": "Translation key: jwt_errors_failedToDecode"
|
||||
},
|
||||
"jwt_errors_invalidFormat": {
|
||||
"message": "JWT 格式错误:必须包含三个由 . 分隔的部分",
|
||||
"description": "Translation key: jwt_errors_invalidFormat"
|
||||
},
|
||||
"jwt_errors_parseHeaderFailed": {
|
||||
"message": "解析 Header 失败:",
|
||||
"description": "Translation key: jwt_errors_parseHeaderFailed"
|
||||
},
|
||||
"jwt_errors_parsePayloadFailed": {
|
||||
"message": "解析 Payload 失败:",
|
||||
"description": "Translation key: jwt_errors_parsePayloadFailed"
|
||||
},
|
||||
"qrCode_pageTitle": {
|
||||
"message": "二维码工具",
|
||||
"description": "Translation key: qrCode_pageTitle"
|
||||
},
|
||||
"qrCode_urlToQr": {
|
||||
"message": "文本转二维码",
|
||||
"description": "Translation key: qrCode_urlToQr"
|
||||
},
|
||||
"qrCode_qrToUrl": {
|
||||
"message": "二维码转文本",
|
||||
"description": "Translation key: qrCode_qrToUrl"
|
||||
},
|
||||
"qrCode_urlInputLabel": {
|
||||
"message": "输入 URL 或文本",
|
||||
"description": "Translation key: qrCode_urlInputLabel"
|
||||
},
|
||||
"qrCode_urlInputPlaceholder": {
|
||||
"message": "请输入 URL 或文本内容,将自动生成二维码",
|
||||
"description": "Translation key: qrCode_urlInputPlaceholder"
|
||||
},
|
||||
"qrCode_generating": {
|
||||
"message": "生成中...",
|
||||
"description": "Translation key: qrCode_generating"
|
||||
},
|
||||
"qrCode_qrCodeWillShow": {
|
||||
"message": "二维码将显示在这里",
|
||||
"description": "Translation key: qrCode_qrCodeWillShow"
|
||||
},
|
||||
"qrCode_downloadButton": {
|
||||
"message": "下载二维码",
|
||||
"description": "Translation key: qrCode_downloadButton"
|
||||
},
|
||||
"qrCode_copyQrButton": {
|
||||
"message": "复制二维码",
|
||||
"description": "Translation key: qrCode_copyQrButton"
|
||||
},
|
||||
"qrCode_qrCodeDownloadSuccess": {
|
||||
"message": "二维码下载成功",
|
||||
"description": "Translation key: qrCode_qrCodeDownloadSuccess"
|
||||
},
|
||||
"qrCode_qrCodeCopySuccess": {
|
||||
"message": "二维码已复制到剪贴板",
|
||||
"description": "Translation key: qrCode_qrCodeCopySuccess"
|
||||
},
|
||||
"qrCode_parseSuccess": {
|
||||
"message": "二维码解析成功",
|
||||
"description": "Translation key: qrCode_parseSuccess"
|
||||
},
|
||||
"qrCode_noQrDetected": {
|
||||
"message": "未检测到二维码,请确保图片清晰且包含二维码",
|
||||
"description": "Translation key: qrCode_noQrDetected"
|
||||
},
|
||||
"qrCode_parseError": {
|
||||
"message": "解析二维码失败,请重试",
|
||||
"description": "Translation key: qrCode_parseError"
|
||||
},
|
||||
"qrCode_copyError": {
|
||||
"message": "复制失败,请重试",
|
||||
"description": "Translation key: qrCode_copyError"
|
||||
},
|
||||
"qrCode_imagePasted": {
|
||||
"message": "图片粘贴成功,正在解析...",
|
||||
"description": "Translation key: qrCode_imagePasted"
|
||||
},
|
||||
"qrCode_imagePasteError": {
|
||||
"message": "粘贴图片失败,请重试",
|
||||
"description": "Translation key: qrCode_imagePasteError"
|
||||
},
|
||||
"qrCode_imageCleared": {
|
||||
"message": "图片已清除",
|
||||
"description": "Translation key: qrCode_imageCleared"
|
||||
},
|
||||
"qrCode_clickToUpload": {
|
||||
"message": "点击、拖拽或粘贴上传二维码图片",
|
||||
"description": "Translation key: qrCode_clickToUpload"
|
||||
},
|
||||
"qrCode_supportFormats": {
|
||||
"message": "支持 PNG、JPG、WEBP、Base64 格式",
|
||||
"description": "Translation key: qrCode_supportFormats"
|
||||
},
|
||||
"qrCode_resultLabel": {
|
||||
"message": "解析结果",
|
||||
"description": "Translation key: qrCode_resultLabel"
|
||||
},
|
||||
"qrCode_clickToChange": {
|
||||
"message": "点击更换图片",
|
||||
"description": "Translation key: qrCode_clickToChange"
|
||||
},
|
||||
"qrCode_generateButton": {
|
||||
"message": "生成二维码",
|
||||
"description": "Translation key: qrCode_generateButton"
|
||||
},
|
||||
"qrCode_editButton": {
|
||||
"message": "编辑",
|
||||
"description": "Translation key: qrCode_editButton"
|
||||
},
|
||||
"qrCode_textPreviewLabel": {
|
||||
"message": "原始文本",
|
||||
"description": "Translation key: qrCode_textPreviewLabel"
|
||||
},
|
||||
"qrCode_generateFirstHint": {
|
||||
"message": "输入文本后点击「生成二维码」按钮",
|
||||
"description": "Translation key: qrCode_generateFirstHint"
|
||||
},
|
||||
"qrCode_inputRequired": {
|
||||
"message": "请输入内容",
|
||||
"description": "Translation key: qrCode_inputRequired"
|
||||
},
|
||||
"qrCode_generateError": {
|
||||
"message": "生成二维码失败,请重试",
|
||||
"description": "Translation key: qrCode_generateError"
|
||||
},
|
||||
"qrCode_uploadedImage": {
|
||||
"message": "已上传图片",
|
||||
"description": "Translation key: qrCode_uploadedImage"
|
||||
},
|
||||
"qrCode_reuploadButton": {
|
||||
"message": "重新上传",
|
||||
"description": "Translation key: qrCode_reuploadButton"
|
||||
},
|
||||
"qrCode_parsing": {
|
||||
"message": "解析中...",
|
||||
"description": "Translation key: qrCode_parsing"
|
||||
},
|
||||
"qrCode_resultPlaceholder": {
|
||||
"message": "解析结果将显示在此处",
|
||||
"description": "Translation key: qrCode_resultPlaceholder"
|
||||
},
|
||||
"qrCode_imageToQr": {
|
||||
"message": "解析图片二维码",
|
||||
"description": "Translation key: qrCode_imageToQr"
|
||||
},
|
||||
"rightClickRestorer_loading": {
|
||||
"message": "正在加载...",
|
||||
"description": "Translation key: rightClickRestorer_loading"
|
||||
},
|
||||
"rightClickRestorer_currentDomain": {
|
||||
"message": "当前域名",
|
||||
"description": "Translation key: rightClickRestorer_currentDomain"
|
||||
},
|
||||
"rightClickRestorer_statusLocked": {
|
||||
"message": "未解锁",
|
||||
"description": "Translation key: rightClickRestorer_statusLocked"
|
||||
},
|
||||
"rightClickRestorer_statusUnlocked": {
|
||||
"message": "已解锁",
|
||||
"description": "Translation key: rightClickRestorer_statusUnlocked"
|
||||
},
|
||||
"rightClickRestorer_unsupported": {
|
||||
"message": "不支持",
|
||||
"description": "Translation key: rightClickRestorer_unsupported"
|
||||
},
|
||||
"rightClickRestorer_unsupportedDesc": {
|
||||
"message": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
|
||||
"description": "Translation key: rightClickRestorer_unsupportedDesc"
|
||||
},
|
||||
"rightClickRestorer_unlockDesc": {
|
||||
"message": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
|
||||
"description": "Translation key: rightClickRestorer_unlockDesc"
|
||||
},
|
||||
"rightClickRestorer_unlockBtn": {
|
||||
"message": "解锁当前网站右键",
|
||||
"description": "Translation key: rightClickRestorer_unlockBtn"
|
||||
},
|
||||
"rightClickRestorer_alreadyUnlocked": {
|
||||
"message": "右键已解锁",
|
||||
"description": "Translation key: rightClickRestorer_alreadyUnlocked"
|
||||
},
|
||||
"storageCleaner_pageTitle": {
|
||||
"message": "存储清理",
|
||||
"description": "Translation key: storageCleaner_pageTitle"
|
||||
},
|
||||
"storageCleaner_loading": {
|
||||
"message": "加载中...",
|
||||
"description": "Translation key: storageCleaner_loading"
|
||||
},
|
||||
"storageCleaner_initializing": {
|
||||
"message": "正在读取站点数据...",
|
||||
"description": "Translation key: storageCleaner_initializing"
|
||||
},
|
||||
"storageCleaner_cleaning": {
|
||||
"message": "正在清理...",
|
||||
"description": "Translation key: storageCleaner_cleaning"
|
||||
},
|
||||
"storageCleaner_cleanNow": {
|
||||
"message": "立即清理",
|
||||
"description": "Translation key: storageCleaner_cleanNow"
|
||||
},
|
||||
"storageCleaner_autoRefresh": {
|
||||
"message": "清理后自动刷新页面",
|
||||
"description": "Translation key: storageCleaner_autoRefresh"
|
||||
},
|
||||
"storageCleaner_selectAll": {
|
||||
"message": "全选所有项",
|
||||
"description": "Translation key: storageCleaner_selectAll"
|
||||
},
|
||||
"storageCleaner_noData": {
|
||||
"message": "无数据",
|
||||
"description": "Translation key: storageCleaner_noData"
|
||||
},
|
||||
"storageCleaner_errorNoTab": {
|
||||
"message": "无法获取当前标签页",
|
||||
"description": "Translation key: storageCleaner_errorNoTab"
|
||||
},
|
||||
"storageCleaner_errorRestricted": {
|
||||
"message": "存储清理功能不支持此页面",
|
||||
"description": "Translation key: storageCleaner_errorRestricted"
|
||||
},
|
||||
"storageCleaner_cleanSuccessReload": {
|
||||
"message": "清理成功,即将刷新页面",
|
||||
"description": "Translation key: storageCleaner_cleanSuccessReload"
|
||||
},
|
||||
"storageCleaner_errorStandardOnly": {
|
||||
"message": "存储清理功能仅适用于标准网页",
|
||||
"description": "Translation key: storageCleaner_errorStandardOnly"
|
||||
},
|
||||
"storageCleaner_confirmTitle": {
|
||||
"message": "确认清理数据?",
|
||||
"description": "Translation key: storageCleaner_confirmTitle"
|
||||
},
|
||||
"storageCleaner_confirmDesc": {
|
||||
"message": "您将永久删除当前页面的以下选定存储项。",
|
||||
"description": "Translation key: storageCleaner_confirmDesc"
|
||||
},
|
||||
"storageCleaner_irreversible": {
|
||||
"message": "此操作不可撤销",
|
||||
"description": "Translation key: storageCleaner_irreversible"
|
||||
},
|
||||
"storageCleaner_confirmAction": {
|
||||
"message": "确认清理",
|
||||
"description": "Translation key: storageCleaner_confirmAction"
|
||||
},
|
||||
"storageCleaner_cleanedSummary": {
|
||||
"message": "清理了 {{items}}",
|
||||
"description": "Translation key: storageCleaner_cleanedSummary"
|
||||
},
|
||||
"storageCleaner_noDataToClean": {
|
||||
"message": "该页面没有可清理的存储数据",
|
||||
"description": "Translation key: storageCleaner_noDataToClean"
|
||||
},
|
||||
"storageCleaner_partialFailure": {
|
||||
"message": "部分清理失败",
|
||||
"description": "Translation key: storageCleaner_partialFailure"
|
||||
},
|
||||
"storageCleaner_options_localStorage": {
|
||||
"message": "Local Storage",
|
||||
"description": "Translation key: storageCleaner_options_localStorage"
|
||||
},
|
||||
"storageCleaner_options_sessionStorage": {
|
||||
"message": "Session Storage",
|
||||
"description": "Translation key: storageCleaner_options_sessionStorage"
|
||||
},
|
||||
"storageCleaner_options_indexedDB": {
|
||||
"message": "站点存储",
|
||||
"description": "Translation key: storageCleaner_options_indexedDB"
|
||||
},
|
||||
"storageCleaner_options_cookies": {
|
||||
"message": "Cookies",
|
||||
"description": "Translation key: storageCleaner_options_cookies"
|
||||
},
|
||||
"storageCleaner_options_cacheStorage": {
|
||||
"message": "Cache Storage",
|
||||
"description": "Translation key: storageCleaner_options_cacheStorage"
|
||||
},
|
||||
"storageCleaner_options_serviceWorkers": {
|
||||
"message": "Service Workers",
|
||||
"description": "Translation key: storageCleaner_options_serviceWorkers"
|
||||
},
|
||||
"textStatistics_pageTitle": {
|
||||
"message": "文本统计",
|
||||
"description": "Translation key: textStatistics_pageTitle"
|
||||
},
|
||||
"textStatistics_placeholder": {
|
||||
"message": "在此输入或粘贴文本...",
|
||||
"description": "Translation key: textStatistics_placeholder"
|
||||
},
|
||||
"textStatistics_characters": {
|
||||
"message": "字符数",
|
||||
"description": "Translation key: textStatistics_characters"
|
||||
},
|
||||
"textStatistics_words": {
|
||||
"message": "单词数",
|
||||
"description": "Translation key: textStatistics_words"
|
||||
},
|
||||
"textStatistics_lines": {
|
||||
"message": "行数",
|
||||
"description": "Translation key: textStatistics_lines"
|
||||
},
|
||||
"textStatistics_bytes": {
|
||||
"message": "字节大小",
|
||||
"description": "Translation key: textStatistics_bytes"
|
||||
},
|
||||
"timestamp_pageTitle": {
|
||||
"message": "时间戳转换",
|
||||
"description": "Translation key: timestamp_pageTitle"
|
||||
},
|
||||
"timestamp_tsToDate": {
|
||||
"message": "时间戳 → 日期",
|
||||
"description": "Translation key: timestamp_tsToDate"
|
||||
},
|
||||
"timestamp_dateToTs": {
|
||||
"message": "日期 → 时间戳",
|
||||
"description": "Translation key: timestamp_dateToTs"
|
||||
},
|
||||
"timestamp_placeholderTs": {
|
||||
"message": "输入时间戳...",
|
||||
"description": "Translation key: timestamp_placeholderTs"
|
||||
},
|
||||
"timestamp_placeholderDate": {
|
||||
"message": "YYYY-MM-DD HH:mm:ss",
|
||||
"description": "Translation key: timestamp_placeholderDate"
|
||||
},
|
||||
"timestamp_unitMs": {
|
||||
"message": "毫秒 (ms)",
|
||||
"description": "Translation key: timestamp_unitMs"
|
||||
},
|
||||
"timestamp_unitS": {
|
||||
"message": "秒 (s)",
|
||||
"description": "Translation key: timestamp_unitS"
|
||||
},
|
||||
"timestamp_currentTs": {
|
||||
"message": "当前时间戳",
|
||||
"description": "Translation key: timestamp_currentTs"
|
||||
},
|
||||
"timestamp_useNowTooltip": {
|
||||
"message": "填充到下方",
|
||||
"description": "Translation key: timestamp_useNowTooltip"
|
||||
},
|
||||
"timestamp_copyTsTooltip": {
|
||||
"message": "复制时间戳",
|
||||
"description": "Translation key: timestamp_copyTsTooltip"
|
||||
},
|
||||
"timestamp_usedSuccess": {
|
||||
"message": "已使用当前时间戳",
|
||||
"description": "Translation key: timestamp_usedSuccess"
|
||||
},
|
||||
"timestamp_resultLabel": {
|
||||
"message": "转换结果",
|
||||
"description": "Translation key: timestamp_resultLabel"
|
||||
},
|
||||
"timestamp_copyResultTooltip": {
|
||||
"message": "复制结果",
|
||||
"description": "Translation key: timestamp_copyResultTooltip"
|
||||
},
|
||||
"timestamp_relativeTime": {
|
||||
"message": "相对时间",
|
||||
"description": "Translation key: timestamp_relativeTime"
|
||||
},
|
||||
"timestamp_iso8601": {
|
||||
"message": "ISO 8601",
|
||||
"description": "Translation key: timestamp_iso8601"
|
||||
},
|
||||
"timestamp_utcTime": {
|
||||
"message": "UTC 时间",
|
||||
"description": "Translation key: timestamp_utcTime"
|
||||
},
|
||||
"timestamp_copyTooltip": {
|
||||
"message": "复制",
|
||||
"description": "Translation key: timestamp_copyTooltip"
|
||||
},
|
||||
"timestamp_resultEmpty": {
|
||||
"message": "请输入并点击转换",
|
||||
"description": "Translation key: timestamp_resultEmpty"
|
||||
},
|
||||
"common_buttons_cancel": {
|
||||
"message": "取消",
|
||||
"description": "Translation key: buttons_cancel"
|
||||
},
|
||||
"common_buttons_copy": {
|
||||
"message": "复制",
|
||||
"description": "Translation key: buttons_copy"
|
||||
},
|
||||
"common_buttons_themeMode_light": {
|
||||
"message": "切换到深色模式",
|
||||
"description": "Translation key: buttons_themeMode_light"
|
||||
},
|
||||
"common_buttons_themeMode_dark": {
|
||||
"message": "切换到系统模式",
|
||||
"description": "Translation key: buttons_themeMode_dark"
|
||||
},
|
||||
"common_buttons_themeMode_system": {
|
||||
"message": "切换到浅色模式",
|
||||
"description": "Translation key: buttons_themeMode_system"
|
||||
},
|
||||
"common_buttons_search": {
|
||||
"message": "搜索工具...",
|
||||
"description": "Translation key: buttons_search"
|
||||
},
|
||||
"common_buttons_back": {
|
||||
"message": "返回",
|
||||
"description": "Translation key: buttons_back"
|
||||
},
|
||||
"common_buttons_clearSearch": {
|
||||
"message": "清除搜索",
|
||||
"description": "Translation key: buttons_clearSearch"
|
||||
},
|
||||
"common_buttons_recentSearch": {
|
||||
"message": "最近搜索",
|
||||
"description": "Translation key: buttons_recentSearch"
|
||||
},
|
||||
"common_buttons_noResults": {
|
||||
"message": "未找到相关工具",
|
||||
"description": "Translation key: buttons_noResults"
|
||||
},
|
||||
"common_buttons_openInTab": {
|
||||
"message": "在标签页打开",
|
||||
"description": "Translation key: buttons_openInTab"
|
||||
},
|
||||
|
||||
"common_errorBoundary_title": {
|
||||
"message": "糟糕,出了点问题",
|
||||
"description": "Translation key: errorBoundary_title"
|
||||
},
|
||||
"common_errorBoundary_description": {
|
||||
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||
"description": "Translation key: errorBoundary_description"
|
||||
},
|
||||
"common_errorBoundary_refresh": {
|
||||
"message": "刷新应用",
|
||||
"description": "Translation key: errorBoundary_refresh"
|
||||
},
|
||||
"common_errorBoundary_retry": {
|
||||
"message": "重新尝试",
|
||||
"description": "Translation key: errorBoundary_retry"
|
||||
},
|
||||
"common_pageErrorBoundary_title": {
|
||||
"message": "该功能运行异常",
|
||||
"description": "Translation key: pageErrorBoundary_title"
|
||||
},
|
||||
"common_pageErrorBoundary_description": {
|
||||
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
|
||||
"description": "Translation key: pageErrorBoundary_description"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 559 B After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 916 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 10 KiB |
@@ -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 的语义化类名
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
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 { t } = useI18n('common');
|
||||
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(t('messages.copyEmpty'));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await copyTextToClipboard(text);
|
||||
if (success) {
|
||||
toast.success(t('messages.copySuccess'));
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error(t('messages.copyError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={tooltip ?? t('buttons.copy')}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
copied &&
|
||||
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||
) : (
|
||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* DecodeResultPaper
|
||||
*
|
||||
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
|
||||
* 提取了二者 decode 输出区完全一致的结构:
|
||||
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
|
||||
*
|
||||
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
|
||||
*/
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatFileSize } from '@/utils/base64Converter';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface DecodeResultPaperProps {
|
||||
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
||||
title: string;
|
||||
/** 解码后推断的 MIME 类型 */
|
||||
mimeType: string;
|
||||
/** 解码后 Blob 的大小(字节) */
|
||||
blobSize: number;
|
||||
/** 当前文件名 */
|
||||
fileName: string;
|
||||
/** 文件名变更回调 */
|
||||
onFileNameChange: (name: string) => void;
|
||||
/** 下载按钮点击回调 */
|
||||
onDownload: () => void;
|
||||
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DecodeResultPaper({
|
||||
title,
|
||||
mimeType,
|
||||
blobSize,
|
||||
fileName,
|
||||
onFileNameChange,
|
||||
onDownload,
|
||||
children,
|
||||
}: DecodeResultPaperProps) {
|
||||
const { t } = useI18n('base64Converter');
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
{/* 标题 */}
|
||||
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
|
||||
|
||||
{/* 可选预览内容(ImageMode 的图片) */}
|
||||
{children}
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex gap-4 mb-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('inferredMimeType')}: {mimeType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('decodedSize')}: {formatFileSize(blobSize)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文件名输入 */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('decodedFileName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => onFileNameChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onDownload}
|
||||
disabled={!fileName.trim()}
|
||||
className="w-full rounded-lg font-bold"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
|
||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<h2 className="text-xl font-extrabold text-destructive mb-2">
|
||||
{getMessage('errorBoundary_title')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{getMessage('errorBoundary_description')}
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={this.handleReset}
|
||||
className="rounded-lg font-bold shadow-sm"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{getMessage('errorBoundary_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export { ErrorBoundary };
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Image, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
/** 选中的文件 */
|
||||
selectedFile: File | null;
|
||||
/** 文件变更回调 */
|
||||
onFileChange: (file: File) => void;
|
||||
/** 清除文件回调 */
|
||||
onClearFile: () => void;
|
||||
/** 文件预览 URL */
|
||||
previewUrl: string;
|
||||
/** 预览 URL 变更回调 */
|
||||
onPreviewUrlChange: (url: string) => void;
|
||||
/** 是否正在拖拽 */
|
||||
dragging: boolean;
|
||||
/** 拖拽状态变更回调 */
|
||||
onDraggingChange: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
const ImageUploader = ({
|
||||
selectedFile,
|
||||
onFileChange,
|
||||
onClearFile,
|
||||
previewUrl,
|
||||
onPreviewUrlChange,
|
||||
dragging,
|
||||
onDraggingChange,
|
||||
}: ImageUploaderProps) => {
|
||||
const { t } = useI18n('qrCode');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
onFileChange(file);
|
||||
onPreviewUrlChange(URL.createObjectURL(file));
|
||||
},
|
||||
[onFileChange, onPreviewUrlChange],
|
||||
);
|
||||
|
||||
const handleClearFile = useCallback(() => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
onClearFile();
|
||||
toast.success(t('qrCode:imageCleared'));
|
||||
}, [previewUrl, onClearFile, t]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileChange(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
onDraggingChange(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听粘贴事件
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
handleFileChange(file);
|
||||
toast.success(t('qrCode:imagePasted'));
|
||||
} catch (error) {
|
||||
console.error('处理粘贴图片失败:', error);
|
||||
toast.error(t('qrCode:imagePasteError'));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [handleFileChange, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||
dragging
|
||||
? 'border-green-600 bg-green-50'
|
||||
: selectedFile
|
||||
? 'border-green-600 bg-green-50/50'
|
||||
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
|
||||
{selectedFile ? (
|
||||
<div className="text-center w-full relative">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code Preview"
|
||||
className="max-w-full max-h-40 rounded-lg object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ClearIcon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearFile();
|
||||
}}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
data-testid="ImageIcon"
|
||||
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
|
||||
/>
|
||||
<span className="block text-sm text-muted-foreground mb-1">
|
||||
{t('qrCode:clickToUpload')}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('qrCode:supportFormats')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||
{getMessage('pageErrorBoundary_title')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-5">
|
||||
{getMessage('pageErrorBoundary_description')}
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
||||
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
{this.state.error.stack || this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="font-medium shadow-sm"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{getMessage('errorBoundary_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,84 @@
|
||||
import React from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** 二维码 Data URL */
|
||||
qrCodeDataUrl: string;
|
||||
/** 下载回调 */
|
||||
onDownload: () => void;
|
||||
/** 复制回调 */
|
||||
onCopy: () => void;
|
||||
/** 自定义占位文本,用于空状态提示 */
|
||||
placeholderText?: string;
|
||||
}
|
||||
|
||||
const QrCodePreview = ({
|
||||
qrCodeDataUrl,
|
||||
onDownload,
|
||||
onCopy,
|
||||
placeholderText,
|
||||
className,
|
||||
...props
|
||||
}: QrCodePreviewProps) => {
|
||||
const { t } = useI18n('qrCode');
|
||||
|
||||
// 空状态下的虚线骨架屏
|
||||
if (!qrCodeDataUrl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{placeholderText || t('qrCode:qrCodeWillShow')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-xs">
|
||||
{/*
|
||||
二维码容器适配:
|
||||
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||
通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||
*/}
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-2 mt-3">
|
||||
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
|
||||
<Download className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="truncate text-xs">{t('qrCode:downloadButton')}</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span className="truncate text-xs">{t('qrCode:copyQrButton')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodePreview;
|
||||
@@ -0,0 +1,27 @@
|
||||
# components/
|
||||
|
||||
通用业务组件目录,存放跨页面复用的 UI 组件,与具体工具页面解耦。
|
||||
|
||||
## 组件列表
|
||||
|
||||
| 组件 | 用途 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||
| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 |
|
||||
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||
| `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,56 @@
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { Suspense } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
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 { t } = useI18n('common');
|
||||
|
||||
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">{t('router.notFound')}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||
{t('router.notFoundDescription', { entryPointType })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
size = 'medium',
|
||||
className,
|
||||
buttonClassName,
|
||||
...props
|
||||
}: SwitchButtonGroupProps<T>) {
|
||||
const sizeClasses = {
|
||||
small: 'text-xs h-8 px-2 py-1 rounded-md',
|
||||
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
|
||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||
};
|
||||
|
||||
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) => {
|
||||
const isSelected = value === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
sizeClasses[size],
|
||||
isSelected
|
||||
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
|
||||
: 'hover:bg-background/50 hover:text-foreground/80',
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
|
||||
export type ValidateRule = {
|
||||
validator: (value: string) => boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToolbarAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
position?: 'top' | 'bottom';
|
||||
type?: 'primary' | 'default' | 'danger';
|
||||
disabled?: boolean | ((value: string) => boolean);
|
||||
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||
};
|
||||
|
||||
export interface TextInputAreaProps extends Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'onChange'
|
||||
> {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
|
||||
/** 值变化回调,返回最新的字符串内容 */
|
||||
onChange?: (value: string) => void;
|
||||
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
showCount?: boolean;
|
||||
showClear?: boolean;
|
||||
allowCopy?: boolean;
|
||||
rules?: ValidateRule[];
|
||||
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||
actions?: ToolbarAction[];
|
||||
topExtra?: React.ReactNode;
|
||||
title?: string;
|
||||
externalError?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
||||
function ActionButton({
|
||||
action,
|
||||
value,
|
||||
globalDisabled,
|
||||
onAction,
|
||||
}: {
|
||||
action: ToolbarAction;
|
||||
value: string;
|
||||
globalDisabled: boolean;
|
||||
onAction: (action: ToolbarAction) => void;
|
||||
}) {
|
||||
const isBtnDisabled =
|
||||
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
default:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction(action)}
|
||||
disabled={isBtnDisabled || globalDisabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
variantClasses[action.type || 'default'],
|
||||
)}
|
||||
>
|
||||
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||
const {
|
||||
value: controlledValue,
|
||||
defaultValue = '',
|
||||
onChange,
|
||||
placeholder: placeholderProp,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
autoFocus = false,
|
||||
minRows = 4,
|
||||
maxRows = 12,
|
||||
maxLength,
|
||||
className,
|
||||
showCount = false,
|
||||
showClear = true,
|
||||
allowCopy = false,
|
||||
rules = [],
|
||||
validateTrigger = 'onAction',
|
||||
actions = [],
|
||||
topExtra,
|
||||
title,
|
||||
externalError,
|
||||
onClear,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const { t } = useI18n('common');
|
||||
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
||||
|
||||
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 = t('charCount', { count: maxLength });
|
||||
setError(msg);
|
||||
toast.warning(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isControlled) setInternalValue(newVal);
|
||||
onChange?.(newVal);
|
||||
|
||||
if (error) setError('');
|
||||
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||
};
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (!isControlled) setInternalValue('');
|
||||
onChange?.('');
|
||||
setError('');
|
||||
internalRef.current?.focus();
|
||||
toast.success(t('textInputArea.cleared'));
|
||||
onClear?.();
|
||||
}, [isControlled, onChange, onClear, t]);
|
||||
|
||||
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={t('textInputArea.copyContent')}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-1"
|
||||
/>
|
||||
)}
|
||||
{showClear && value && !disabled && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
aria-label={t('textInputArea.clear')}
|
||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{displayError && (
|
||||
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{displayError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextInputArea.displayName = 'TextInputArea';
|
||||
|
||||
export default TextInputArea;
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SEARCH_HISTORY_LIMIT = 10;
|
||||
const SEARCH_HISTORY_DISPLAY = 5;
|
||||
|
||||
export default function TopBar() {
|
||||
const { currentPage, goBack, navigateTo } = useRouter();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { t } = useI18n(['common', 'features']);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleOpenInTab = async () => {
|
||||
await openExtensionPage('popup.html', { mode: 'tab' });
|
||||
window.close();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Cmd/Ctrl+K 快捷键聚焦搜索框
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setShowResults(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
storageUtil
|
||||
.get('app/searchHistory', [])
|
||||
.then((history) => {
|
||||
if (history) setSearchHistory(history);
|
||||
})
|
||||
.catch((err) => console.error('加载搜索历史失败:', err));
|
||||
}, []);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return FEATURES.filter((f) => {
|
||||
if (f.key === 'dashboard') return false;
|
||||
return (
|
||||
t(f.labelKey).toLowerCase().includes(query) ||
|
||||
t(f.descriptionKey).toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [searchQuery, t]);
|
||||
|
||||
const displayedHistory = useMemo(() => {
|
||||
if (searchQuery.trim()) return [];
|
||||
return searchHistory
|
||||
.slice(0, SEARCH_HISTORY_DISPLAY)
|
||||
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) }))
|
||||
.filter((item) => item.feature && item.feature.key !== 'dashboard');
|
||||
}, [searchHistory, searchQuery]);
|
||||
|
||||
const saveToHistory = async (featureKey: string) => {
|
||||
if (!featureKey.trim()) return;
|
||||
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice(
|
||||
0,
|
||||
SEARCH_HISTORY_LIMIT,
|
||||
);
|
||||
setSearchHistory(nextHistory);
|
||||
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleSelectFeature = (feature: FeatureConfig) => {
|
||||
navigateTo(feature.key);
|
||||
saveToHistory(feature.key);
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||
setMode(nextMap[mode]);
|
||||
};
|
||||
|
||||
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
||||
if (searchQuery.trim()) {
|
||||
handleSelectFeature(searchResults[selectedIndex]);
|
||||
} else {
|
||||
const selected = displayedHistory[selectedIndex];
|
||||
if (selected?.feature) {
|
||||
handleSelectFeature(selected.feature);
|
||||
}
|
||||
}
|
||||
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||
handleSelectFeature(searchResults[0]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowResults(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const isDashboard = currentPage === 'dashboard';
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
||||
{/* 左侧:返回按钮区 */}
|
||||
<div className="flex w-10 items-center justify-start">
|
||||
{!isDashboard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
aria-label={t('common_buttons_back')}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:搜索容器 */}
|
||||
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 group-focus-within:text-muted-foreground transition-colors pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('common_buttons_search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setShowResults(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setShowResults(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={t('common_buttons_search')}
|
||||
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
||||
/>
|
||||
{!searchQuery && (
|
||||
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 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 pointer-events-none">
|
||||
⌘K
|
||||
</kbd>
|
||||
)}
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
aria-label={t('common:buttons.clearSearch')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 动态联想结果卡片 */}
|
||||
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 bg-popover text-popover-foreground rounded-lg shadow-lg border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-2 duration-150">
|
||||
<ul role="listbox" className="p-1.5">
|
||||
{searchQuery.trim() ? (
|
||||
searchResults.length > 0 ? (
|
||||
searchResults.map((feature, index) => (
|
||||
<li
|
||||
key={feature.key}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => handleSelectFeature(feature)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors',
|
||||
selectedIndex === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
'bg-muted/80 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{feature.icon && <feature.icon className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{t(feature.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{t(feature.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t('common:buttons.noResults')}
|
||||
</li>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t('common:buttons.recentSearch')}
|
||||
</div>
|
||||
{displayedHistory.map((item, index) => (
|
||||
<li
|
||||
key={item.key}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => item.feature && handleSelectFeature(item.feature)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors',
|
||||
selectedIndex === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||
'bg-muted/80 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.feature?.icon && <item.feature.icon className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{item.feature && t(item.feature.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{item.feature && t(item.feature.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作区 */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
|
||||
<ThemeIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
|
||||
function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
|
||||
describe('DecodeResultPaper 组件', () => {
|
||||
const defaultProps = {
|
||||
title: 'decodedFileOutput',
|
||||
mimeType: 'image/png',
|
||||
blobSize: 1024,
|
||||
fileName: 'decoded.png',
|
||||
onFileNameChange: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 MIME 类型信息', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应通过 formatFileSize 渲染文件大小', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
|
||||
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染文件名输入框', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染下载按钮', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 children 内容', () => {
|
||||
render(
|
||||
<DecodeResultPaper {...defaultProps}>
|
||||
<div data-testid="preview">预览内容</div>
|
||||
</DecodeResultPaper>,
|
||||
);
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('预览内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('修改文件名时应调用 onFileNameChange', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
fireEvent.change(input, { target: { value: 'new-name.png' } });
|
||||
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
|
||||
});
|
||||
|
||||
it('点击下载按钮时应调用 onDownload', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('按钮状态', () => {
|
||||
it('文件名为空时下载按钮应禁用', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('文件名不为空时下载按钮应启用', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
|
||||
// 用于触发错误的测试子组件
|
||||
function ThrowError({ message }: { message: string }): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 正常渲染的子组件
|
||||
function NormalComponent({ text }: { text: string }) {
|
||||
return <div data-testid="normal-content">{text}</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
// 抑制测试中故意抛出的错误日志
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('正常渲染子组件', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent text="正常内容" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||
});
|
||||
|
||||
it('子组件抛出错误时显示错误 UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="测试错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('children 变化时重置错误状态', async () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="初始错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
|
||||
// 切换到正常子组件
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent text="恢复后的内容" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('相同的 children 不重置错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="相同子组件错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
|
||||
// 用相同的 children rerender
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="相同子组件错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误 UI 包含刷新按钮', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { reload: reloadMock },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="按钮测试" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
|
||||
refreshButton.click();
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import ImageUploader from '@/components/ImageUploader';
|
||||
|
||||
// 配置多端一致性常驻桩(WXT 规范)
|
||||
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
|
||||
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
|
||||
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
|
||||
|
||||
// 模拟 URL API
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('ImageUploader 组件', () => {
|
||||
const mockOnFileChange = vi.fn();
|
||||
const mockOnClearFile = vi.fn();
|
||||
const mockOnPreviewUrlChange = vi.fn();
|
||||
const mockOnDraggingChange = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: null,
|
||||
onFileChange: mockOnFileChange,
|
||||
onClearFile: mockOnClearFile,
|
||||
previewUrl: '',
|
||||
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||
dragging: false,
|
||||
onDraggingChange: mockOnDraggingChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当没有选中文件时应显示上传提示', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByText(/点击.*拖拽/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/格式/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示文件预览', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
expect(screen.getByText(/点击更换/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示预览图片', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||
});
|
||||
|
||||
it('当选中文件时应显示清除按钮', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应包含隐藏的文件输入框', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文件选择交互', () => {
|
||||
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||
});
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('拖拽交互', () => {
|
||||
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragOver(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragLeave(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true });
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: [mockFile],
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent(dropzone, dropEvent);
|
||||
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清除文件功能', () => {
|
||||
it('点击清除按钮时应调用 onClearFile', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('粘贴功能', () => {
|
||||
it('监听粘贴事件', () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
|
||||
it('组件卸载时应移除粘贴事件监听', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
const { unmount } = render(<ImageUploader {...defaultProps} />);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('拖拽状态时应应用拖拽样式', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('有文件时应应用有文件样式', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
const { container } = render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
expect(dropzone).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { PageErrorBoundary } from '@/components/PageErrorBoundary';
|
||||
|
||||
// 用于触发错误的测试子组件
|
||||
function ThrowError({ message }: { message: string }): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 正常渲染的子组件
|
||||
function NormalComponent({ text }: { text: string }) {
|
||||
return <div data-testid="normal-content">{text}</div>;
|
||||
}
|
||||
|
||||
describe('PageErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
// 抑制测试中故意抛出的错误日志
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('正常渲染子组件', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<NormalComponent text="正常内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||
});
|
||||
|
||||
it('子组件抛出错误时显示错误卡片 UI', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="测试错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击重试按钮后恢复', async () => {
|
||||
const { rerender } = render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="可恢复错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
|
||||
// 将子组件替换为正常组件,然后点击重试
|
||||
rerender(
|
||||
<PageErrorBoundary>
|
||||
<NormalComponent text="恢复后的内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||
retryButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resetKey 变化时自动重置错误状态', async () => {
|
||||
const { rerender } = render(
|
||||
<PageErrorBoundary resetKey="page-a">
|
||||
<ThrowError message="页面 A 错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
|
||||
// 切换 resetKey,同时提供正常子组件
|
||||
rerender(
|
||||
<PageErrorBoundary resetKey="page-b">
|
||||
<NormalComponent text="页面 B 内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('页面 B 内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('该功能运行异常')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resetKey 不变时保持错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<PageErrorBoundary resetKey="page-a">
|
||||
<ThrowError message="初始错误" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
|
||||
// 仅 children 变化,resetKey 不变,错误应保持
|
||||
rerender(
|
||||
<PageErrorBoundary resetKey="page-a">
|
||||
<NormalComponent text="新内容" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('该功能运行异常')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误 UI 包含重试按钮', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="按钮测试" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /重新尝试/ });
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误信息以 monospace 格式显示', () => {
|
||||
render(
|
||||
<PageErrorBoundary>
|
||||
<ThrowError message="格式化测试" />
|
||||
</PageErrorBoundary>,
|
||||
);
|
||||
|
||||
const errorText = screen.getByText(/格式化测试/);
|
||||
expect(errorText).toBeInTheDocument();
|
||||
expect(errorText.tagName.toLowerCase()).toBe('pre');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
|
||||
describe('PageSkeleton 组件', () => {
|
||||
describe('渲染测试', () => {
|
||||
it('默认应渲染 dashboard 骨架屏', () => {
|
||||
const { container } = render(<PageSkeleton />);
|
||||
|
||||
// dashboard 骨架屏包含 6 个卡片
|
||||
const cards = container.querySelectorAll('.rounded-xl');
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it('variant 为 dashboard 时应渲染仪表盘卡片骨架', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
|
||||
// 每个卡片有 2 个骨架元素(图标、文本),6 个卡片共 12 个
|
||||
const cards = container.querySelectorAll('.rounded-xl');
|
||||
expect(cards.length).toBe(6);
|
||||
});
|
||||
|
||||
it('variant 为 tool 时应渲染工具页面骨架', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
|
||||
// tool 骨架屏包含标题、输入区、控制栏 3 个按钮、结果区
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('布局结构测试', () => {
|
||||
it('dashboard 骨架屏应使用 grid 布局', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
const gridContainer = container.firstChild as HTMLElement;
|
||||
|
||||
expect(gridContainer).toHaveClass('grid');
|
||||
});
|
||||
|
||||
it('tool 骨架屏应有内边距', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
const toolContainer = container.firstChild as HTMLElement;
|
||||
|
||||
expect(toolContainer).toHaveClass('p-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('骨架屏元素测试', () => {
|
||||
it('dashboard 骨架屏应包含圆角和边框样式', () => {
|
||||
const { container } = render(<PageSkeleton variant="dashboard" />);
|
||||
|
||||
// 获取第一个卡片容器
|
||||
const card = container.querySelector('.rounded-xl.border');
|
||||
expect(card).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tool 骨架屏应包含动画脉冲效果', () => {
|
||||
const { container } = render(<PageSkeleton variant="tool" />);
|
||||
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import QrCodePreview from '@/components/QrCodePreview';
|
||||
|
||||
describe('QrCodePreview 组件', () => {
|
||||
const mockOnDownload = vi.fn();
|
||||
const mockOnCopy = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当 qrCodeDataUrl 为空时应显示占位文本', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.getByText(/二维码将显示/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||
const testDataUrl = 'data:image/png;base64,test123';
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl={testDataUrl}
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', testDataUrl);
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/下载二维码/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/复制二维码/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.queryByText(/下载二维码/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/复制二维码/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/下载二维码/));
|
||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText(/复制二维码/));
|
||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('RouterContainer 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(<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/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(/站点存储/)).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,490 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
|
||||
describe('TextInputArea 组件', () => {
|
||||
describe('基础渲染', () => {
|
||||
it('应渲染 placeholder', () => {
|
||||
render(<TextInputArea placeholder="请输入文本..." />);
|
||||
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染传入的 value', () => {
|
||||
render(<TextInputArea value="测试内容" onChange={() => {}} />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveValue('测试内容');
|
||||
});
|
||||
|
||||
it('默认显示清空按钮', () => {
|
||||
render(<TextInputArea value="有内容" onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无内容时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('readOnly 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showClear=false 时不显示清空按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('受控模式', () => {
|
||||
it('输入时触发 onChange', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮触发 onChange("")', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('非受控模式', () => {
|
||||
it('defaultValue 应显示初始值', () => {
|
||||
render(<TextInputArea defaultValue="初始值" />);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('初始值');
|
||||
});
|
||||
|
||||
it('输入后应更新内部值', () => {
|
||||
render(<TextInputArea defaultValue="" />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(textarea).toHaveValue('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮应清空内容', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowCopy 复制功能', () => {
|
||||
it('allowCopy 且有内容时显示复制按钮', () => {
|
||||
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy 但无内容时隐藏复制按钮', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy=false 时不显示复制按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('复制时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
|
||||
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockRejectedValue(new Error('失败'));
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showCount 字符计数', () => {
|
||||
it('显示当前字符数', () => {
|
||||
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('空内容时显示 0', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('设置 maxLength 时显示计数上限', () => {
|
||||
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
|
||||
expect(screen.getByText('2 / 10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength', () => {
|
||||
it('超出 maxLength 的输入应被截断', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123456' } });
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('未超出 maxLength 的输入应正常触发', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('验证规则', () => {
|
||||
it('onChange 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'ab' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onBlur 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onBlur"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.blur(textarea);
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('验证通过不应显示错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'abcd' } });
|
||||
|
||||
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onAction 触发时验证失败应阻止 action 执行', () => {
|
||||
const handleAction = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onAction"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
actions={[
|
||||
{
|
||||
key: 'test',
|
||||
label: '执行',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('执行'));
|
||||
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('操作栏 actions', () => {
|
||||
it('应渲染顶部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('顶部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染底部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('底部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击操作按钮触发 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('操作'));
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(
|
||||
'内容',
|
||||
expect.objectContaining({
|
||||
clear: expect.any(Function),
|
||||
setError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled 为 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disabled 为函数且返回 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('primary 类型按钮应使用 contained 样式', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const btn = screen.getByText('主要');
|
||||
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('应渲染 title', () => {
|
||||
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
|
||||
expect(screen.getByText('输入区域')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('不设置 title 时不渲染标题', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled 和 readOnly', () => {
|
||||
it('disabled 时输入框应禁用', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('readOnly 时输入框应只读', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFocus', () => {
|
||||
it('autoFocus 应自动聚焦', () => {
|
||||
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('复制功能', () => {
|
||||
it('复制成功时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear 回调', () => {
|
||||
it('点击清空按钮时应调用 onClear', () => {
|
||||
const handleClear = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('不传 onClear 时清空按钮应正常工作', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('externalError 外部错误', () => {
|
||||
it('设置 externalError 时应显示错误状态', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
|
||||
|
||||
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 为空时应隐藏错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||
|
||||
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
|
||||
|
||||
expect(screen.queryByText('错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 优先级高于内部验证错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 清除后应显示内部验证错误', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'a' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoResize', () => {
|
||||
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||
);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('autoResize=false 时设置固定 rows', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式集成', () => {
|
||||
it('应透传 className', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ToolCard from '@/pages/Dashboard/ToolCard';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
describe('ToolCard 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题和描述', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="测试工具"
|
||||
description="这是一个测试工具"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onNavigate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
||||
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无描述时仅渲染标题', () => {
|
||||
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||
|
||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染图标', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('提供快照内容时应渲染快照', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="带快照"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onNavigate={() => {}}
|
||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('未提供快照时不渲染快照区域', () => {
|
||||
const { container } = render(
|
||||
<ToolCard
|
||||
title="无快照"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onClick={() => {}}
|
||||
onNavigate={function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击时应调用 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可点击/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('按 Enter 键时应调用 onClick', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
||||
await act(async () => {
|
||||
button.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
});
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('应应用自定义颜色代码', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('TopBar 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeModeProvider>
|
||||
<RouterProvider>{ui}</RouterProvider>
|
||||
</ThemeModeProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar />);
|
||||
expect(screen.getByLabelText('返回')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<TopBar />);
|
||||
expect(screen.queryByLabelText('返回')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击返回按钮时应调用 goBack', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('返回'));
|
||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,101 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,44 @@
|
||||
# config/
|
||||
|
||||
应用级配置目录,存放功能特性的注册中心。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------- | ---------------------------------------- |
|
||||
| `features.tsx` | 核心配置文件,定义所有工具功能的注册信息 |
|
||||
|
||||
## features.tsx
|
||||
|
||||
`FEATURES` 数组是路由和功能元数据的**单一事实来源**,每个功能定义包含:
|
||||
|
||||
- `key`:页面类型标识(`PageType`)
|
||||
- `labelKey` / `descriptionKey`:i18n 翻译键
|
||||
- `themeColorKey`:主题色(`primary/success/warning/error/secondary/info`)
|
||||
- `icon`:lucide-react 图标组件
|
||||
- `defaultVisible`:默认是否可见
|
||||
- `components`:三种渲染模式的懒加载组件(`popup`、`sidepanel`、`tab`)
|
||||
|
||||
## 已注册功能(11 个)
|
||||
|
||||
| key | 图标 | 说明 |
|
||||
| -------------------- | ----------------- | ----------------- |
|
||||
| `dashboard` | — | 仪表盘首页 |
|
||||
| `timestamp` | Clock | 时间戳转换工具 |
|
||||
| `storageCleaner` | Database | 存储清理工具 |
|
||||
| `qrCode` | QrCode | 二维码工具 |
|
||||
| `textStatistics` | FileText | 文本统计工具 |
|
||||
| `jwt` | Key | JWT 解析工具 |
|
||||
| `jsonDiff` | GitCompareArrows | JSON 差异比较工具 |
|
||||
| `base64Converter` | ArrowLeftRight | Base64 转换器 |
|
||||
| `markdownToHtml` | Code | Markdown 转 HTML |
|
||||
| `htmlToMarkdown` | File | HTML 转 Markdown |
|
||||
| `rightClickRestorer` | MousePointerClick | 右键菜单恢复工具 |
|
||||
|
||||
## 导出函数
|
||||
|
||||
- `getFeatureByKey(key)` — 根据 key 获取功能配置
|
||||
- `getDefaultVisibleFeatureKeys()` — 获取默认可见的功能 key 列表
|
||||
- `getAllFeatureKeys()` — 获取所有功能 key 列表
|
||||
- `getDefaultPageOrder()` — 获取默认页面排序(不含 dashboard)
|
||||
- `getEntryPointType()` — 判断当前入口类型(popup/sidepanel/tab)
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FEATURES,
|
||||
getAllFeatureKeys,
|
||||
getDefaultPageOrder,
|
||||
getDefaultVisibleFeatureKeys,
|
||||
getFeatureByKey,
|
||||
} from '@/config/features';
|
||||
|
||||
describe('features', () => {
|
||||
describe('FEATURES', () => {
|
||||
it('should have 9 features defined', () => {
|
||||
expect(FEATURES).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should have all required properties for each feature', () => {
|
||||
FEATURES.forEach((feature) => {
|
||||
expect(feature).toHaveProperty('key');
|
||||
expect(feature).toHaveProperty('labelKey');
|
||||
expect(feature).toHaveProperty('descriptionKey');
|
||||
expect(feature).toHaveProperty('defaultVisible');
|
||||
expect(feature).toHaveProperty('components');
|
||||
expect(typeof feature.key).toBe('string');
|
||||
expect(typeof feature.labelKey).toBe('string');
|
||||
expect(typeof feature.descriptionKey).toBe('string');
|
||||
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||
expect(typeof feature.components).toBe('object');
|
||||
expect(feature.components).toHaveProperty('popup');
|
||||
expect(feature.components).toHaveProperty('sidepanel');
|
||||
expect(feature.components).toHaveProperty('tab');
|
||||
|
||||
// Optional UI properties for non-hidden features
|
||||
if (feature.key !== 'dashboard') {
|
||||
expect(feature).toHaveProperty('icon');
|
||||
expect(feature).toHaveProperty('themeColorKey');
|
||||
expect(typeof feature.themeColorKey).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique keys for each feature', () => {
|
||||
const keys = FEATURES.map((f) => f.key);
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatureByKey', () => {
|
||||
it('should return dashboard feature', () => {
|
||||
const feature = getFeatureByKey('dashboard');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('dashboard');
|
||||
expect(feature?.labelKey).toBe('dashboard_title');
|
||||
});
|
||||
|
||||
it('should return timestamp feature', () => {
|
||||
const feature = getFeatureByKey('timestamp');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('timestamp');
|
||||
expect(feature?.labelKey).toBe('timestamp_title');
|
||||
expect(feature?.themeColorKey).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return storageCleaner feature', () => {
|
||||
const feature = getFeatureByKey('storageCleaner');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('storageCleaner');
|
||||
expect(feature?.labelKey).toBe('storageCleaner_title');
|
||||
});
|
||||
|
||||
it('should return undefined for invalid key', () => {
|
||||
const feature = getFeatureByKey('invalid' as any);
|
||||
expect(feature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultVisibleFeatureKeys', () => {
|
||||
it('should return only visible features', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
visibleKeys.forEach((key) => {
|
||||
const feature = getFeatureByKey(key);
|
||||
expect(feature?.defaultVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include dashboard, timestamp, storageCleaner, qrCode', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
expect(visibleKeys).toContain('dashboard');
|
||||
expect(visibleKeys).toContain('timestamp');
|
||||
expect(visibleKeys).toContain('storageCleaner');
|
||||
expect(visibleKeys).toContain('qrCode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFeatureKeys', () => {
|
||||
it('should return all feature keys', () => {
|
||||
const allKeys = getAllFeatureKeys();
|
||||
expect(allKeys).toHaveLength(9);
|
||||
expect(allKeys).toContain('dashboard');
|
||||
expect(allKeys).toContain('timestamp');
|
||||
expect(allKeys).toContain('storageCleaner');
|
||||
expect(allKeys).toContain('qrCode');
|
||||
expect(allKeys).toContain('textStatistics');
|
||||
expect(allKeys).toContain('jwt');
|
||||
expect(allKeys).toContain('jsonDiff');
|
||||
expect(allKeys).toContain('base64Converter');
|
||||
expect(allKeys).toContain('rightClickRestorer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultPageOrder', () => {
|
||||
it('should exclude dashboard from page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).not.toContain('dashboard');
|
||||
});
|
||||
|
||||
it('should include timestamp, storageCleaner, qrCode in page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).toContain('timestamp');
|
||||
expect(pageOrder).toContain('storageCleaner');
|
||||
expect(pageOrder).toContain('qrCode');
|
||||
});
|
||||
|
||||
it('should have 8 items in page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
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,
|
||||
} 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'));
|
||||
|
||||
export interface FeatureConfig {
|
||||
key: PageType;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
themeColorKey?: PaletteColorKey;
|
||||
icon?: ComponentType<LucideProps>;
|
||||
defaultVisible: boolean;
|
||||
components: {
|
||||
popup: ComponentType;
|
||||
sidepanel: ComponentType;
|
||||
tab: ComponentType;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEATURES: FeatureConfig[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
labelKey: 'dashboard_title',
|
||||
descriptionKey: '',
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: DashboardPage,
|
||||
sidepanel: DashboardPage,
|
||||
tab: DashboardPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
labelKey: 'timestamp_title',
|
||||
descriptionKey: 'timestamp_description',
|
||||
themeColorKey: 'primary',
|
||||
icon: Clock,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: TimestampPage,
|
||||
sidepanel: TimestampPage,
|
||||
tab: TimestampPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'storageCleaner',
|
||||
labelKey: 'storageCleaner_title',
|
||||
descriptionKey: 'storageCleaner_description',
|
||||
themeColorKey: 'warning',
|
||||
icon: Database,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: StorageCleanerPage,
|
||||
sidepanel: StorageCleanerPage,
|
||||
tab: StorageCleanerPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'qrCode',
|
||||
labelKey: 'qrCode_title',
|
||||
descriptionKey: 'qrCode_description',
|
||||
themeColorKey: 'success',
|
||||
icon: QrCode,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: QrCodePage,
|
||||
sidepanel: QrCodePage,
|
||||
tab: QrCodePage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'textStatistics',
|
||||
labelKey: 'textStatistics_title',
|
||||
descriptionKey: 'textStatistics_description',
|
||||
themeColorKey: 'secondary',
|
||||
icon: FileText,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: TextStatisticsPage,
|
||||
sidepanel: TextStatisticsPage,
|
||||
tab: TextStatisticsPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'jwt',
|
||||
labelKey: 'jwt_title',
|
||||
descriptionKey: 'jwt_description',
|
||||
themeColorKey: 'info',
|
||||
icon: Key,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: JwtPage,
|
||||
sidepanel: JwtPage,
|
||||
tab: JwtPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'jsonDiff',
|
||||
labelKey: 'jsonDiff_title',
|
||||
descriptionKey: 'jsonDiff_description',
|
||||
themeColorKey: 'primary',
|
||||
icon: GitCompareArrows,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: JsonToolsPage,
|
||||
sidepanel: JsonToolsPage,
|
||||
tab: JsonToolsPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'base64Converter',
|
||||
labelKey: 'base64Converter_title',
|
||||
descriptionKey: 'base64Converter_description',
|
||||
themeColorKey: 'info',
|
||||
icon: ArrowLeftRight,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: Base64ConverterPage,
|
||||
sidepanel: Base64ConverterPage,
|
||||
tab: Base64ConverterPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rightClickRestorer',
|
||||
labelKey: 'rightClickRestorer_title',
|
||||
descriptionKey: 'rightClickRestorer_description',
|
||||
themeColorKey: 'success',
|
||||
icon: MousePointerClick,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: RightClickRestorerPage,
|
||||
sidepanel: RightClickRestorerPage,
|
||||
tab: RightClickRestorerPage,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||
return FEATURES.find((f) => f.key === key);
|
||||
}
|
||||
|
||||
export function getDefaultVisibleFeatureKeys(): PageType[] {
|
||||
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
|
||||
}
|
||||
|
||||
export function getAllFeatureKeys(): PageType[] {
|
||||
return FEATURES.map((f) => f.key);
|
||||
}
|
||||
|
||||
export function getDefaultPageOrder(): PageType[] {
|
||||
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
|
||||
}
|
||||
|
||||
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
|
||||
const pathname = window.location.pathname;
|
||||
if (pathname.includes('sidepanel')) {
|
||||
return 'sidepanel';
|
||||
}
|
||||
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
|
||||
return 'tab';
|
||||
}
|
||||
return 'popup';
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# entrypoints/
|
||||
|
||||
WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各个上下文。
|
||||
|
||||
## 入口文件
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `background.ts` | Service Worker 入口:注册右键菜单、监听菜单点击、处理消息通信、管理侧边栏状态、注入主环境脚本 |
|
||||
| `content.ts` | Content Script 入口:注入所有页面(`<all_urls>`),在 `document_end` 时初始化消息处理器 |
|
||||
| `rightClickRestorer.content.ts` | 专用 Content Script:处理右键菜单恢复功能,注入浮动状态徽章 |
|
||||
|
||||
## 子目录
|
||||
|
||||
### popup/
|
||||
|
||||
Popup 弹窗页面(点击扩展图标弹出)。
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ------------ | ------------------------------------------------------------------------------ |
|
||||
| `index.html` | HTML 入口 |
|
||||
| `main.tsx` | React 挂载点 |
|
||||
| `App.tsx` | 根组件,组装 `RouterProvider` + `TopBar` + `ErrorBoundary` + `RouterContainer` |
|
||||
|
||||
### sidepanel/
|
||||
|
||||
侧边栏页面,结构与 popup 类似,额外通知 background 侧边栏开启/关闭状态。
|
||||
|
||||
### options/
|
||||
|
||||
设置页面,支持:
|
||||
|
||||
- 拖拽排序功能顺序(`@dnd-kit`)
|
||||
- 功能可见性管理(显示/隐藏)
|
||||
- Popup/Sidepanel/Tab 三种模式独立配置
|
||||
|
||||
### content/
|
||||
|
||||
Content Script 内部分模块:
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ----------------------- | ------------------------------------------------------------------- |
|
||||
| `messageHandler.ts` | 消息处理器初始化入口 |
|
||||
| `contextMenuHandler.ts` | 右键菜单点击事件处理,执行时间戳转换/文本统计并通过 UI Popover 展示 |
|
||||
| `uiPopover.ts` | 在页面中注入浮层 Popover UI,展示右键菜单操作结果 |
|
||||
|
||||
## 架构说明
|
||||
|
||||
- `background.ts` 是扩展的核心协调者,处理跨上下文通信
|
||||
- `content.ts` 注入到所有页面,负责接收和处理来自 background 的消息
|
||||
- `popup/`、`sidepanel/`、`options/` 共享同一套页面组件(来自 `pages/`),通过 `RouterProvider` 的不同配置实现独立路由
|
||||
@@ -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,86 @@
|
||||
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] Injection request missing tabId');
|
||||
return { success: false, message: 'Missing 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] executeScript failed:', 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,74 @@
|
||||
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
||||
import { MessageAction, onMessage } from '@/utils/messages';
|
||||
import { getTextStats } from '@/utils/textStatistics';
|
||||
import { getMessage } from '@/utils/chromeI18n';
|
||||
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||
|
||||
function convertTimestamp(input: string): string {
|
||||
const invalidText = getMessage('invalidTimestamp') || 'Invalid Timestamp';
|
||||
const num = Number(input.trim());
|
||||
|
||||
if (isNaN(num)) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
// 1e12 判定毫秒级/秒级时间戳兼容
|
||||
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
let lastClickX = 0;
|
||||
let lastClickY = 0;
|
||||
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
lastClickX = e.clientX;
|
||||
lastClickY = e.clientY;
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
export function initContextMenuHandler(): void {
|
||||
const dismissPopover = (): void => {
|
||||
hidePopover();
|
||||
};
|
||||
|
||||
document.addEventListener('click', dismissPopover, { passive: true });
|
||||
document.addEventListener('scroll', dismissPopover, { passive: true });
|
||||
window.addEventListener('resize', dismissPopover, { passive: true });
|
||||
|
||||
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
|
||||
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
|
||||
|
||||
switch (featureKey) {
|
||||
case 'timestamp': {
|
||||
const result = convertTimestamp(payload);
|
||||
showTimestampResult(lastClickX, lastClickY, payload, result);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'textStatistics': {
|
||||
const stats = getTextStats(payload);
|
||||
showTextStatsResult(lastClickX, lastClickY, payload, stats);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
hidePopover();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
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 {
|
||||
// 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽
|
||||
const rect = popover.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点
|
||||
let top = y + 8;
|
||||
|
||||
if (left + rect.width > viewportWidth - 16) {
|
||||
left = viewportWidth - rect.width - 16;
|
||||
}
|
||||
if (left < 16) left = 16;
|
||||
|
||||
if (top + rect.height > viewportHeight - 16) {
|
||||
top = y - rect.height - 8;
|
||||
}
|
||||
if (top < 16) top = 16;
|
||||
|
||||
popover.style.left = `${left}px`;
|
||||
popover.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function showPopover(
|
||||
x: number,
|
||||
y: number,
|
||||
contentHtml: string,
|
||||
title?: string,
|
||||
duration: number = 5000,
|
||||
): void {
|
||||
const popover = getOrCreatePopover();
|
||||
|
||||
popover.innerHTML = '';
|
||||
|
||||
if (title) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'popover-header';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'popover-title';
|
||||
titleSpan.textContent = title; // ✅ 强安全性护航
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'popover-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
popover.classList.remove('visible');
|
||||
});
|
||||
|
||||
header.appendChild(titleSpan);
|
||||
header.appendChild(closeBtn);
|
||||
popover.appendChild(header);
|
||||
}
|
||||
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'popover-content';
|
||||
contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义
|
||||
popover.appendChild(contentContainer);
|
||||
|
||||
// 提前移除激活类名,使 visibility: hidden 起效以供测量
|
||||
popover.classList.remove('visible');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
positionPopover(popover, x, y);
|
||||
popover.classList.add('visible');
|
||||
});
|
||||
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
|
||||
if (duration > 0) {
|
||||
hideTimeout = setTimeout(() => {
|
||||
hidePopover();
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function hidePopover(): void {
|
||||
const popover = document.getElementById(POPOVER_ID);
|
||||
if (popover) {
|
||||
popover.classList.remove('visible');
|
||||
}
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
|
||||
// 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗
|
||||
const cleanTimestamp = escapeHtml(timestamp);
|
||||
const cleanResult = escapeHtml(result);
|
||||
|
||||
const content = `
|
||||
<div class="popover-label">输入时间戳</div>
|
||||
<div class="popover-value">${cleanTimestamp}</div>
|
||||
<div class="popover-label">转换结果</div>
|
||||
<div class="popover-value">${cleanResult}</div>
|
||||
`;
|
||||
showPopover(x, y, content, '⏰ 时间戳转换');
|
||||
}
|
||||
|
||||
export function showTextStatsResult(
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
stats: { characters: number; words: number; lines: number; bytes: number },
|
||||
): void {
|
||||
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||
// 对选中的脏文本先进行严格转义
|
||||
const cleanText = escapeHtml(truncatedText);
|
||||
|
||||
const content = `
|
||||
<div class="popover-label">选中文本</div>
|
||||
<div class="popover-value">${cleanText}</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字符</div>
|
||||
<div class="stat-value">${stats.characters}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">单词</div>
|
||||
<div class="stat-value">${stats.words}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">行数</div>
|
||||
<div class="stat-value">${stats.lines}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字节</div>
|
||||
<div class="stat-value">${stats.bytes}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
showPopover(x, y, content, '📊 文本统计');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import RouterProvider from '@/providers/RouterProvider';
|
||||
import TopBar from '@/components/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,36 @@
|
||||
<!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" />
|
||||
<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 AppRoot from '@/providers/AppRoot';
|
||||
import '@/index.css';
|
||||
import App from './App.tsx';
|
||||
import './style.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AppRoot>
|
||||
<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 '@/components/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>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,7 @@
|
||||
<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" />
|
||||
<title>Testing Tools - Side Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
@@ -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 @@
|
||||
# lib/
|
||||
|
||||
通用库工具目录,存放与业务无关的底层工具函数。
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ---------- | ------------------------------------ |
|
||||
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
|
||||
|
||||
## cn()
|
||||
|
||||
组合 `clsx` + `tailwind-merge`,用于合并和去重 Tailwind CSS 类名:
|
||||
|
||||
```typescript
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// 条件类名 + 合并外部 className
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 px-3',
|
||||
isActive && 'bg-primary text-primary-foreground',
|
||||
className,
|
||||
)}>
|
||||
```
|
||||
|
||||
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Image as ImageIcon, Trash2, Upload } from 'lucide-react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { downloadBlob, formatFileSize } from '@/utils/base64Converter';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import type { Base64ConvertDirection } from '@/types/storage';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { useBase64Converter } from './useBase64Converter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const isValidDirection = (val: unknown): val is Base64ConvertDirection =>
|
||||
val === 'encode' || val === 'decode';
|
||||
|
||||
interface Base64ConverterSectionProps {
|
||||
mode: 'file' | 'image';
|
||||
}
|
||||
|
||||
export default function Base64ConverterSection({ mode }: Base64ConverterSectionProps) {
|
||||
const { t } = useI18n('base64Converter');
|
||||
|
||||
const [direction, setDirection] = useStorageState(
|
||||
`base64Converter/${mode}Mode/direction`,
|
||||
'encode',
|
||||
isValidDirection,
|
||||
);
|
||||
|
||||
const {
|
||||
result,
|
||||
info,
|
||||
isLoading,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
fileInputRef,
|
||||
encodeError,
|
||||
decodeInput,
|
||||
setDecodeInput,
|
||||
decoded,
|
||||
decodeError,
|
||||
decodedFileName,
|
||||
setCustomFileName,
|
||||
resetAll,
|
||||
safeFileSelect,
|
||||
maxFileSizeStr,
|
||||
} = useBase64Converter({ mode });
|
||||
|
||||
const handleDirectionChange = (next: Base64ConvertDirection) => {
|
||||
if (!next || next === direction) return;
|
||||
resetAll();
|
||||
setDirection(next);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (decoded) downloadBlob(decoded.blob, decodedFileName);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4 px-2">
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{direction === 'encode' ? (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) safeFileSelect(file);
|
||||
}}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center min-h-[190px] border-2 border-dashed rounded-2xl p-8 cursor-pointer',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/10'
|
||||
: info
|
||||
? 'border-primary/60 bg-primary/5'
|
||||
: 'border-border bg-muted/40 hover:border-primary/80 hover:bg-muted/70',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={mode === 'image' ? 'image/*' : undefined}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) safeFileSelect(file);
|
||||
}}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="w-9 h-9 border-3 border-primary/20 border-t-primary rounded-full animate-spin" />
|
||||
) : info ? (
|
||||
<div className="flex flex-col items-center gap-1.5 text-center w-full">
|
||||
{mode === 'image' && result && (
|
||||
<div className="relative p-1 border border-border bg-background rounded-lg shadow-sm mb-1 max-w-[180px] overflow-hidden">
|
||||
<img
|
||||
src={result.output}
|
||||
alt="preview"
|
||||
className="max-h-32 w-full object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Upload className={cn('w-8 h-8 text-primary')} />
|
||||
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
|
||||
{info.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/80 font-mono tabular-nums">
|
||||
{formatFileSize(info.size)} · {info.type}
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-primary/80 mt-1">
|
||||
{t('clickOrDropToReplace')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
{mode === 'image' ? (
|
||||
<ImageIcon className="w-8 h-8 text-muted-foreground/60" />
|
||||
) : (
|
||||
<Upload className="w-8 h-8 text-muted-foreground/60" />
|
||||
)}
|
||||
<span className="text-xs font-bold text-foreground/80">
|
||||
{mode === 'image' ? t('clickOrDropToImage') : t('clickOrDropToFile')}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground/60">
|
||||
{t('maxFileSize', { max: maxFileSizeStr })}
|
||||
</span>
|
||||
{mode === 'image' && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50">
|
||||
{t('supportedFormats')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{encodeError && (
|
||||
<div className="p-3.5 bg-destructive/10 border border-destructive/20 rounded-xl text-xs font-semibold text-destructive">
|
||||
{encodeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{t('base64Output')}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<CopyButton
|
||||
text={result.rawBase64}
|
||||
tooltip={t('copyRaw')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
<CopyButton
|
||||
text={result.output}
|
||||
tooltip={t('copyDataUri')}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={
|
||||
result.output.length > 2000
|
||||
? `${result.output.substring(0, 2000)}...`
|
||||
: result.output
|
||||
}
|
||||
showClear={false}
|
||||
minRows={4}
|
||||
/>
|
||||
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
|
||||
<div className="flex gap-4 items-center tabular-nums">
|
||||
<span>
|
||||
{t('originalSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.originalBytes)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-border/60">|</span>
|
||||
<span>
|
||||
{t('encodedSize')}:{' '}
|
||||
<span className="font-semibold text-foreground/80">
|
||||
{formatFileSize(result.outputBytes)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={resetAll}
|
||||
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TextInputArea
|
||||
placeholder={t('decodeBase64Placeholder')}
|
||||
value={decodeInput}
|
||||
onChange={setDecodeInput}
|
||||
externalError={decodeError || undefined}
|
||||
showClear={true}
|
||||
allowCopy={true}
|
||||
minRows={6}
|
||||
onClear={resetAll}
|
||||
/>
|
||||
{decoded && (
|
||||
<DecodeResultPaper
|
||||
title={mode === 'image' ? t('decodedImageOutput') : t('decodedFileOutput')}
|
||||
mimeType={decoded.mimeType}
|
||||
blobSize={decoded.blob.size}
|
||||
fileName={decodedFileName}
|
||||
onFileNameChange={setCustomFileName}
|
||||
onDownload={handleDownload}
|
||||
>
|
||||
{mode === 'image' && (
|
||||
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
|
||||
<img
|
||||
src={`data:${decoded.mimeType};base64,${decoded.rawBase64}`}
|
||||
alt="decoded preview"
|
||||
className="max-h-40 w-full rounded-lg object-contain bg-[linear-gradient(45deg,#ccc_25%,transparent_25%),linear-gradient(-45deg,#ccc_25%,transparent_25%),linear-gradient(45deg,transparent_75%,#ccc_75%),linear-gradient(-45deg,transparent_75%,#ccc_75%)] bg-[size:10px_10px] bg-[position:0_0,0_5px,5px_-5px,-5px_0] dark:bg-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DecodeResultPaper>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import { base64ToText, textToBase64 } from '@/utils/base64Converter';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
|
||||
|
||||
const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
|
||||
'Invalid Base64 string': 'invalidBase64',
|
||||
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
|
||||
'binaryDataDetected',
|
||||
};
|
||||
|
||||
interface TextModeProps {
|
||||
onSwitchToImageMode?: () => void;
|
||||
}
|
||||
|
||||
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
|
||||
const { t } = useI18n('base64Converter');
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [debouncedInput, setDebouncedInput] = useState('');
|
||||
const [direction, setDirection] = useState<'encode' | 'decode'>('encode');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setDebouncedInput(input);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [input]);
|
||||
|
||||
const handleContextMenuData = useCallback((payload: string) => {
|
||||
setInput(payload);
|
||||
setDebouncedInput(payload);
|
||||
setDirection('decode');
|
||||
}, []);
|
||||
|
||||
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
|
||||
|
||||
const conversionPipeline = useMemo(() => {
|
||||
const trimmed = debouncedInput.trim();
|
||||
if (!trimmed) return { output: '', error: null };
|
||||
|
||||
try {
|
||||
if (direction === 'encode') {
|
||||
const result = textToBase64(debouncedInput);
|
||||
return { output: result.output, error: null };
|
||||
} else {
|
||||
const decoded = base64ToText(trimmed);
|
||||
return { output: decoded, error: null };
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '';
|
||||
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
|
||||
return {
|
||||
output: '',
|
||||
error: i18nKey ? t(i18nKey) : message || t('conversionFailed'),
|
||||
};
|
||||
}
|
||||
}, [debouncedInput, direction, t]);
|
||||
|
||||
const output = conversionPipeline.output;
|
||||
const error = conversionPipeline.error;
|
||||
|
||||
const placeholder =
|
||||
direction === 'encode' ? t('textInputPlaceholder') : t('base64InputPlaceholder');
|
||||
const outputLabel = direction === 'encode' ? t('base64Output') : t('textOutput');
|
||||
|
||||
const showImageHint = useMemo(
|
||||
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
|
||||
[direction, input],
|
||||
);
|
||||
|
||||
const handleDirectionChange = (value: 'encode' | 'decode') => {
|
||||
if (value === direction) return;
|
||||
setDirection(value);
|
||||
setInput('');
|
||||
setDebouncedInput('');
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInput('');
|
||||
setDebouncedInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-4 px-2">
|
||||
{/* 受控方向切流中枢 */}
|
||||
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
|
||||
<SwitchButtonGroup
|
||||
value={direction}
|
||||
options={[
|
||||
{ value: 'encode', label: t('encode') },
|
||||
{ value: 'decode', label: t('decode') },
|
||||
]}
|
||||
onChange={handleDirectionChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 高性能受控文本输入端 */}
|
||||
<TextInputArea
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
externalError={error || undefined}
|
||||
showClear={true}
|
||||
allowCopy={true}
|
||||
minRows={5}
|
||||
maxRows={10}
|
||||
onClear={handleClear}
|
||||
/>
|
||||
|
||||
{/* 图片 URI 类型劫持警告引导区:
|
||||
- 💡 修复点:彻底废除原生亮色硬编码 hover:bg-blue-100 类名,
|
||||
- 完美向全站 shadcn 暗黑生态看齐,采用标准的 bg-primary/10 混合变体。
|
||||
*/}
|
||||
{showImageHint && (
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-primary/10 border border-primary/20">
|
||||
<span className="text-xs font-semibold text-primary tracking-tight">
|
||||
{t('imageDataUriHint')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSwitchToImageMode}
|
||||
className="h-7 rounded-md text-xs font-bold text-primary hover:text-primary hover:bg-primary/20 dark:hover:bg-primary/10 px-2.5"
|
||||
>
|
||||
{t('switchToImageMode')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. 编码/解码核心数据承载流卡片 */}
|
||||
{output && (
|
||||
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
|
||||
{outputLabel}
|
||||
</span>
|
||||
<CopyButton
|
||||
text={output}
|
||||
className="h-6 px-2 rounded-md border text-[10px] font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextInputArea
|
||||
readOnly
|
||||
value={output.length > 2000 ? `${output.substring(0, 2000)}...` : output}
|
||||
showClear={false}
|
||||
minRows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import TextMode from '../TextMode';
|
||||
|
||||
// Mock CopyButton
|
||||
vi.mock('@/components/CopyButton', () => ({
|
||||
CopyButton: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
|
||||
default: ({ text }: { text: string }) => <button data-testid="copy-button">{text}</button>,
|
||||
}));
|
||||
|
||||
describe('TextMode', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该渲染编码/解码切换按钮', () => {
|
||||
render(<TextMode />);
|
||||
expect(screen.getByText('编码')).toBeInTheDocument();
|
||||
expect(screen.getByText('解码')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染输入框和转换按钮', () => {
|
||||
render(<TextMode />);
|
||||
expect(screen.getByPlaceholderText('输入需要编码为 Base64 的文本...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该将文本编码为 Base64', async () => {
|
||||
render(<TextMode />);
|
||||
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
|
||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Base64 编码结果')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该解码 Base64 文本', async () => {
|
||||
render(<TextMode />);
|
||||
|
||||
// 切换到解码模式
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
|
||||
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
|
||||
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('解码文本结果')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Hello' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该对无效 Base64 显示错误', async () => {
|
||||
render(<TextMode />);
|
||||
|
||||
// 切换到解码模式
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
|
||||
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
|
||||
fireEvent.change(input, { target: { value: 'invalid!!!' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Base64 字符串无效')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('切换方向时应该清除输出', async () => {
|
||||
render(<TextMode />);
|
||||
|
||||
// 先编码
|
||||
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
|
||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 切换方向
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
|
||||
// 输出应该被清除
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('点击清除按钮应该清空所有内容', async () => {
|
||||
render(<TextMode />);
|
||||
|
||||
const input = screen.getByPlaceholderText('输入需要编码为 Base64 的文本...');
|
||||
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'SGVsbG8=' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'SGVsbG8=' })).not.toBeInTheDocument();
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
it('解码模式下粘贴图片 data URI 时应该显示切换图像模式的提示', () => {
|
||||
render(<TextMode />);
|
||||
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
|
||||
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('切换到图像模式')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('粘贴非图片 data URI 时不应该显示图像模式提示', () => {
|
||||
render(<TextMode />);
|
||||
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
|
||||
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
|
||||
fireEvent.change(input, { target: { value: 'SGVsbG8=' } });
|
||||
|
||||
expect(
|
||||
screen.queryByText('检测到图片的 data URI,请使用「图像」选项卡进行解码。'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击切换图像模式按钮应该调用 onSwitchToImageMode 回调', () => {
|
||||
const onSwitch = vi.fn();
|
||||
render(<TextMode onSwitchToImageMode={onSwitch} />);
|
||||
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('切换到图像模式'));
|
||||
expect(onSwitch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('解码模式下对二进制数据应该显示更清晰的错误', async () => {
|
||||
render(<TextMode />);
|
||||
|
||||
fireEvent.click(screen.getByText('解码'));
|
||||
|
||||
const input = screen.getByPlaceholderText('输入需要解码的 Base64 字符串...');
|
||||
fireEvent.change(input, { target: { value: 'iVBORw0KGgo=' } });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import Base64ConverterPage from '../index';
|
||||
|
||||
// Mock getEntryPointType
|
||||
vi.mock('@/config/features', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config/features')>();
|
||||
return {
|
||||
...actual,
|
||||
getEntryPointType: () => 'sidepanel',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock 子组件
|
||||
vi.mock('../TextMode', () => ({
|
||||
default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
|
||||
<div data-testid="text-mode">
|
||||
TextMode
|
||||
{onSwitchToImageMode && <button onClick={onSwitchToImageMode}>switchToImage</button>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../Base64ConverterSection', () => ({
|
||||
default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>,
|
||||
}));
|
||||
|
||||
const waitForStorageInit = () =>
|
||||
act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
describe('Base64ConverterPage', () => {
|
||||
it('应该默认渲染文本模式', async () => {
|
||||
render(<Base64ConverterPage />);
|
||||
await waitForStorageInit();
|
||||
expect(screen.getByTestId('text-mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应该渲染模式切换按钮', async () => {
|
||||
render(<Base64ConverterPage />);
|
||||
await waitForStorageInit();
|
||||
expect(screen.getByText('文本')).toBeInTheDocument();
|
||||
expect(screen.getByText('文件')).toBeInTheDocument();
|
||||
expect(screen.getByText('图像')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('切换到文件模式应该渲染 FileMode', async () => {
|
||||
render(<Base64ConverterPage />);
|
||||
await waitForStorageInit();
|
||||
fireEvent.click(screen.getByText('文件'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-mode')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('切换到图像模式应该渲染 ImageMode', async () => {
|
||||
render(<Base64ConverterPage />);
|
||||
await waitForStorageInit();
|
||||
fireEvent.click(screen.getByText('图像'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('image-mode')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('text-mode')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import type { Base64ConverterPageMode } from '@/types/storage';
|
||||
import TextMode from './TextMode';
|
||||
import Base64ConverterSection from './Base64ConverterSection';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
|
||||
const VALID_PAGE_MODES: readonly Base64ConverterPageMode[] = ['text', 'file', 'image'];
|
||||
const isValidPageMode = (val: unknown): val is Base64ConverterPageMode =>
|
||||
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
|
||||
|
||||
type PageMode = Base64ConverterPageMode;
|
||||
|
||||
export default function Index() {
|
||||
const { t } = useI18n('base64Converter');
|
||||
const [pageMode, setPageMode] = useStorageState(
|
||||
'base64Converter/pageMode',
|
||||
'text',
|
||||
isValidPageMode,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[520px] select-none">
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
options={[
|
||||
{ value: 'text', label: t('base64Converter:textMode') },
|
||||
{ value: 'file', label: t('base64Converter:fileMode') },
|
||||
{ value: 'image', label: t('base64Converter:imageMode') },
|
||||
]}
|
||||
onChange={(value: PageMode) => setPageMode(value)}
|
||||
size="small"
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
|
||||
<div className="w-full pt-1">
|
||||
{pageMode === 'text' && <TextMode onSwitchToImageMode={() => setPageMode('image')} />}
|
||||
{pageMode === 'file' && <Base64ConverterSection mode="file" />}
|
||||
{pageMode === 'image' && <Base64ConverterSection mode="image" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '@/utils/chromeI18n';
|
||||
import type { FileToBase64Result } from '@/utils/base64Converter';
|
||||
import {
|
||||
base64ToBlob,
|
||||
fileToBase64,
|
||||
isFileSizeValid,
|
||||
isSupportedImageExtension,
|
||||
isSupportedImageType,
|
||||
MAX_FILE_SIZE,
|
||||
} from '@/utils/base64Converter';
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface UseBase64ConverterProps {
|
||||
mode: 'file' | 'image';
|
||||
}
|
||||
|
||||
export function useBase64Converter({ mode }: UseBase64ConverterProps) {
|
||||
const { t } = useI18n('base64Converter');
|
||||
|
||||
const [result, setResult] = useState<FileToBase64Result | null>(null);
|
||||
const [info, setInfo] = useState<FileInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const cancelRef = useRef(false);
|
||||
|
||||
const [decodeInput, setDecodeInput] = useState('');
|
||||
const [debouncedDecodeInput, setDebouncedDecodeInput] = useState('');
|
||||
const [encodeError, setEncodeError] = useState<string | null>(null);
|
||||
const [customFileName, setCustomFileName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setDebouncedDecodeInput(decodeInput);
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [decodeInput]);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
cancelRef.current = true;
|
||||
setResult(null);
|
||||
setInfo(null);
|
||||
setIsLoading(false);
|
||||
setDecodeInput('');
|
||||
setDebouncedDecodeInput('');
|
||||
setCustomFileName('');
|
||||
setEncodeError(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (file: File) => {
|
||||
cancelRef.current = false;
|
||||
setEncodeError(null);
|
||||
setResult(null);
|
||||
setInfo(null);
|
||||
|
||||
if (!isFileSizeValid(file.size)) {
|
||||
setEncodeError(t('fileSizeExceeded', { max: `${MAX_FILE_SIZE / 1024 / 1024} MB` }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
mode === 'image' &&
|
||||
!isSupportedImageType(file.type) &&
|
||||
!isSupportedImageExtension(file.name)
|
||||
) {
|
||||
setEncodeError(t('unsupportedImageType'));
|
||||
return;
|
||||
}
|
||||
|
||||
setInfo({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type || 'application/octet-stream',
|
||||
});
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fileToBase64(file);
|
||||
if (!cancelRef.current) setResult(res);
|
||||
} catch (e) {
|
||||
if (!cancelRef.current) {
|
||||
setEncodeError(e instanceof Error ? e.message : t('conversionFailed'));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelRef.current) setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[mode, t],
|
||||
);
|
||||
|
||||
const safeFileSelect = useCallback(
|
||||
(file: File) => {
|
||||
handleFileSelect(file).catch((err) => {
|
||||
console.error(`Base64 [${mode}] pipeline crash:`, err);
|
||||
});
|
||||
},
|
||||
[handleFileSelect, mode],
|
||||
);
|
||||
|
||||
const decodePipeline = useMemo(() => {
|
||||
const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim();
|
||||
if (!cleanedInput) return { decoded: null, error: null };
|
||||
|
||||
try {
|
||||
const res = base64ToBlob(cleanedInput);
|
||||
return { decoded: res, error: null };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '';
|
||||
return {
|
||||
decoded: null,
|
||||
error: message === 'Invalid Base64 string' ? t('invalidBase64') : t('conversionFailed'),
|
||||
};
|
||||
}
|
||||
}, [debouncedDecodeInput, t]);
|
||||
|
||||
const decoded = decodePipeline.decoded;
|
||||
const decodeError = decodePipeline.error;
|
||||
|
||||
const decodedFileName = useMemo(() => {
|
||||
if (customFileName) return customFileName;
|
||||
if (decoded) return `decoded${decoded.suggestedExtension}`;
|
||||
return '';
|
||||
}, [customFileName, decoded]);
|
||||
|
||||
const maxFileSizeStr = `${MAX_FILE_SIZE / 1024 / 1024} MB`;
|
||||
|
||||
return {
|
||||
result,
|
||||
info,
|
||||
isLoading,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
fileInputRef,
|
||||
encodeError,
|
||||
decodeInput,
|
||||
setDecodeInput,
|
||||
decoded,
|
||||
decodeError,
|
||||
decodedFileName,
|
||||
setCustomFileName,
|
||||
resetAll,
|
||||
safeFileSelect,
|
||||
maxFileSizeStr,
|
||||
};
|
||||
}
|
||||