Compare commits
122 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,153 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 💡 1. 提速核心:前置基建节点(Infrastructure Initialization)
|
||||
# 专门负责锁死环境、同步下载并缓存 node_modules,下游节点直接满血复用!
|
||||
setup:
|
||||
name: Prepare Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-info.outputs.key }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# 建立基于 package-lock.json 唯一哈希的缓存大闸
|
||||
- name: Cache Node Modules
|
||||
id: cache-nodemodules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-v22-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Output Cache Key
|
||||
id: cache-info
|
||||
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# 💡 2. 静态语法质检节点(依赖前置节点完成)
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
# 💡 3. 强类型守卫节点(2秒瞬时恢复,开箱即查)
|
||||
typecheck:
|
||||
name: TypeScript Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Run TypeScript type check
|
||||
run: npm run typecheck
|
||||
|
||||
# 💡 4. 单元测试节点(无缝运行你刚刚修复完的 setupTests.ts 套件)
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
# 💡 5. 多端分布式最终编译节点(Production Matrix Compliance)
|
||||
build:
|
||||
name: Build (${{ matrix.browser }})
|
||||
runs-on: ubuntu-latest
|
||||
# 只有当 Linter、类型大闸、Vitest 单元测试全数满分通过,才放行最终打包编译
|
||||
needs: [ lint, typecheck, test ]
|
||||
strategy:
|
||||
matrix:
|
||||
# 💡 完美对齐 WXT 跨端架构:将 firefox 同步纳入生产编译大矩阵,
|
||||
# 如果 firefox 编译因任何多端不兼容挂掉,CI 会立刻拉起警报,防护力拉满!
|
||||
browser: [ chrome, firefox ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Restore Node Modules Instantantly
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
# 💡 动态代理编译指令:完美匹配 WXT / 各类多端打包器的标准构建命令
|
||||
- name: 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,161 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: 依赖统一前置基础架构(Infrastructure Stage) ───────────────────
|
||||
setup:
|
||||
name: Prepare Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
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 }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-release-v22-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
# ── Phase 2: 全量生产级断言检查(秒级瞬时恢复缓存,安全闭环) ──────────────────
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: setup
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Restore Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-release-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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
- name: Run TypeScript type check
|
||||
run: npm run compile
|
||||
|
||||
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
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
|
||||
# ── Phase 3: 多端分布式高精打包(Compile & Upload Artifacts) ───────────────
|
||||
build-extension:
|
||||
name: Package Extension
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ lint, typecheck, test ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Restore Node Modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
# 执行 WXT 高阶打包压缩指令
|
||||
- name: Build and Zip Extension
|
||||
run: |
|
||||
npm run zip
|
||||
npm run zip:firefox
|
||||
|
||||
# 💡 核心自愈补丁:显式将 .output 下打包出的真实生产绝对路径文件,
|
||||
# 稳固地上存至 GitHub 的常驻产物箱中进行安全物理隔离,防范后期发布网络崩溃导致产物蒸发!
|
||||
- name: Upload Extension Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: extension-zips
|
||||
path: |
|
||||
.output/*.zip
|
||||
retention-days: 7
|
||||
|
||||
# ── Phase 4: 独立中央签发发布(Atomic Release Publisher) ───────────────────
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-extension
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 💡 独立下载打包完好的绝对产物包
|
||||
- name: Download Extension Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: extension-zips
|
||||
path: 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
|
||||
# 百分之百精准指向被下载下来的、毫无路径污染风险的 Zip 包实体
|
||||
files: |
|
||||
release-artifacts/*.zip
|
||||
+7
-1
@@ -13,7 +13,6 @@ stats.html
|
||||
stats-*.json
|
||||
.wxt
|
||||
.vitest
|
||||
.claude
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -25,3 +24,10 @@ stats-*.json
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.trae/*
|
||||
.workbuddy/*
|
||||
.qoder/*
|
||||
dev/*
|
||||
|
||||
docs/*
|
||||
|
||||
@@ -1,143 +1,98 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供测试工具功能,包括时间戳转换等。
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript + MUI v7)。
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `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 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 执行顺序: `lint → typecheck → test → build` (build 依赖前三者)。
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
Pre-commit hook (lint-staged) 顺序:
|
||||
|
||||
## 项目架构
|
||||
1. `prettier --write`
|
||||
2. `eslint --fix --max-warnings=0`
|
||||
3. `tsc --noEmit`
|
||||
|
||||
### 技术栈
|
||||
提交前确保三者通过。
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI (MUI)
|
||||
- **状态管理**: React Hooks
|
||||
- **路由**: React Router DOM
|
||||
|
||||
### 目录结构
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── 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 # 存储相关类型
|
||||
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||
config/pageTheme.ts # 页面级主题常量
|
||||
config/theme.ts # MUI 全局主题
|
||||
entrypoints/ # 扩展入口点 (popup/, options/, sidepanel/, background.ts, content.ts)
|
||||
pages/ # 功能页面组件 (懒加载)
|
||||
components/ # 可复用 UI 组件
|
||||
providers/ # React Context (Router, Theme 等)
|
||||
utils/ # 工具函数与服务抽象
|
||||
types/ # TypeScript 类型声明
|
||||
i18n/locales/{zh,en}/ # 国际化资源 (common.json, features.json)
|
||||
```
|
||||
|
||||
### 核心功能实现
|
||||
## 关键架构决策
|
||||
|
||||
#### 1. 时间戳转换工具
|
||||
**路由**: 不使用 React Router。通过 `config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType` 渲染对应组件。
|
||||
|
||||
- 位置: `components/` 目录下的时间戳相关组件
|
||||
- 依赖: dayjs 库进行日期处理
|
||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式
|
||||
**存储**: 所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中定义。使用 `utils/chromeStorage.ts` 及其 Hook。
|
||||
|
||||
#### 2. 通信系统
|
||||
**通信**: 使用 `@webext-core/messaging`,协议定义在 `utils/messages.ts`。
|
||||
|
||||
- 位置: `utils/messages.tsx`
|
||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗
|
||||
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||
|
||||
#### 3. 数据存储
|
||||
## 测试环境
|
||||
|
||||
- Chrome Storage API: `utils/chromeStorage.ts` (用于配置等小数据)
|
||||
- 环境: jsdom
|
||||
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||
- `chrome.*` API (storage, tabs, runtime, cookies 等)
|
||||
- `react-i18next` (返回 key 作为翻译)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
|
||||
### 关键配置文件
|
||||
## i18n
|
||||
|
||||
#### wxt.config.ts
|
||||
- 命名空间: `common` (默认), `features`
|
||||
- 翻译键格式: `namespace:key` (如 `features:timestamp.title`)
|
||||
- 语言: `zh` (默认), `en`
|
||||
- 添加新翻译: 编辑 `i18n/locales/{zh,en}/{common,features}.json`
|
||||
|
||||
- 配置 WXT 框架参数
|
||||
- 启用 React 模块
|
||||
- 配置浏览器扩展权限
|
||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
||||
## 新功能开发清单
|
||||
|
||||
#### manifest 权限
|
||||
1. 在 `types/storage.d.ts` 添加 `PageType` 联合类型
|
||||
2. 在 `config/features.tsx` 的 `FEATURES` 数组添加配置
|
||||
3. 在 `pages/` 创建页面组件 (懒加载)
|
||||
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译
|
||||
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||
6. 添加对应的单元测试
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage', // 存储权限
|
||||
'unlimitedStorage', // 无限制存储
|
||||
'clipboardWrite', // 剪贴板写入
|
||||
'activeTab', // 当前标签页
|
||||
'scripting', // 脚本注入
|
||||
'tabs', // 标签页管理
|
||||
'debugger', // 调试器
|
||||
],
|
||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
||||
```
|
||||
## 代码规范
|
||||
|
||||
## 开发注意事项
|
||||
- 禁止使用 `any` (测试文件除外)
|
||||
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||
- 样式: 复杂页面样式放 `config/pageTheme.ts`,简单样式用 MUI `sx` prop
|
||||
- 格式: Prettier (100 字符宽, 单引号, 尾逗号)
|
||||
|
||||
### 扩展入口点
|
||||
## 技术栈版本
|
||||
|
||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
- 支持 Chrome 和 Firefox 浏览器
|
||||
- 使用 WXT 框架抽象浏览器差异
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 使用 ESLint 进行代码检查
|
||||
- Husky 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范
|
||||
- WXT: ^0.20.26
|
||||
- React: ^19.2.6
|
||||
- MUI: ^7.3.8
|
||||
- TypeScript: ^5.9.3
|
||||
- Vitest: ^4.1.7
|
||||
- i18next: ^26.2.0
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WXT 框架的浏览器扩展项目,提供时间戳转换工具。项目已精简为核心功能,移除了录制回放等复杂功能。
|
||||
|
||||
## 核心命令
|
||||
|
||||
### 开发相关
|
||||
|
||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
||||
- `npm run zip` - 打包 Chrome 扩展
|
||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
||||
- `npm run lint` - 运行 ESLint 检查
|
||||
|
||||
### 依赖与准备
|
||||
|
||||
- `npm install` - 安装依赖
|
||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
||||
|
||||
### CI/CD
|
||||
|
||||
- GitHub Actions 配置: `.github/workflows/node.js.yml`
|
||||
- 在 main 分支推送或 PR 时触发
|
||||
- 使用 Node.js 22.x 运行 build
|
||||
- 测试命令当前被注释(项目暂无测试)
|
||||
|
||||
## 项目架构
|
||||
|
||||
### 技术栈
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit) - 浏览器扩展开发框架
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI (MUI)
|
||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||
- **通信**: @webext-core/messaging
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── background.ts # 后台脚本(处理扩展安装/更新,注入内容脚本)
|
||||
│ ├── content.ts # 内容内容脚本(注入到页面,当前为空占位)
|
||||
│ ├── popup/ # 扩展弹窗界面
|
||||
│ │ ├── App.tsx # 弹窗主应用
|
||||
│ │ ├── main.tsx # 弹窗入口
|
||||
│ │ ├── index.html # 弹窗 HTML
|
||||
│ │ └── pages/ # 弹窗页面
|
||||
│ │ └── TimestampPage.tsx # 时间戳转换页面(核心功能)
|
||||
│ └── options/ # 选项页面(当前为静态 HTML)
|
||||
│ └── index.html # 选项页 HTML
|
||||
├── utils/ # 工具函数
|
||||
│ ├── chromeStorage.ts # Chrome Storage 工具(类型安全封装)
|
||||
│ ├── dayjs.ts # dayjs 配置(UTC + 时区插件)
|
||||
│ └── messages.tsx # 扩展消息通信工具(@webext-core/messaging)
|
||||
├── types/ # 类型定义
|
||||
│ └── storage.d.ts # StorageSchema 类型定义
|
||||
├── constants/ # 常量定义(当前为空)
|
||||
└── public/ # 静态资源
|
||||
```
|
||||
|
||||
### 核心功能
|
||||
|
||||
#### 时间戳转换工具 (entrypoints/popup/pages/TimestampPage.tsx)
|
||||
|
||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
||||
- 时间戳 → 日期时间转换
|
||||
- 日期时间 → 时间戳转换
|
||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
||||
- 一键复制功能
|
||||
- 输入验证和错误提示
|
||||
|
||||
### 扩展入口点
|
||||
|
||||
- **后台脚本** (`entrypoints/background.ts`):
|
||||
- 监听扩展安装/更新事件
|
||||
- 自动向所有有效标签页注入内容脚本
|
||||
- 过滤受限协议(chrome://, about:// 等)
|
||||
|
||||
- **内容脚本** (`entrypoints/content.ts`):
|
||||
- 匹配所有 URL (`<all_urls>`)
|
||||
- 在文档开始时运行
|
||||
- 当前为占位符,无实际逻辑
|
||||
|
||||
- **弹窗** (`entrypoints/popup/`):
|
||||
- 主入口显示 TimestampPage
|
||||
- 提供时间戳转换的完整功能
|
||||
|
||||
- **选项页** (`entrypoints/options/`):
|
||||
- 当前为静态 HTML 页面
|
||||
- 可扩展为设置界面
|
||||
|
||||
### 数据存储
|
||||
|
||||
使用 Chrome Storage API 进行持久化存储:
|
||||
|
||||
- 类型安全的封装 (`utils/chromeStorage.ts`)
|
||||
- 基于接口定义的 Schema (`types/storage.d.ts`)
|
||||
- 当前支持的存储键:
|
||||
- `app/lastRoute`: 上次访问的路由
|
||||
- `app/theme`: 主题设置
|
||||
|
||||
### 消息通信
|
||||
|
||||
使用 `@webext-core/messaging` 库实现类型安全的扩展内通信:
|
||||
|
||||
- 定义在 `utils/messages.tsx`
|
||||
- 当前 ProtocolMap 为空(预留接口)
|
||||
|
||||
### 关键配置文件
|
||||
|
||||
#### wxt.config.ts
|
||||
|
||||
- 启用 React 模块 (`@wxt-dev/module-react`)
|
||||
- 配置 manifest 权限和 host_permissions
|
||||
- 使用 Terser 压缩(强制 ASCII 编码)
|
||||
- 配置图标和选项页
|
||||
|
||||
#### manifest 权限
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage', // Chrome Storage
|
||||
'unlimitedStorage', // 无限制存储
|
||||
'clipboardWrite', // 剪贴板写入(复制功能)
|
||||
'activeTab', // 当前标签页访问
|
||||
'scripting', // 脚本注入
|
||||
'tabs', // 标签页管理
|
||||
'debugger', // 调试器权限
|
||||
],
|
||||
host_permissions: ['<all_urls>'] // 访问所有网站
|
||||
```
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### 浏览器兼容性
|
||||
|
||||
- 支持 Chrome 和 Firefox 浏览器
|
||||
- 使用 WXT 框架抽象浏览器差异
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 使用 ESLint 进行代码检查(零警告)
|
||||
- Husky 用于 Git 钩子管理
|
||||
- Lint-staged 确保暂存文件符合规范(ESLint + TypeScript + Prettier)
|
||||
- Prettier 用于代码格式化
|
||||
- Prettier 配置: 100 字符行宽,2 空格缩进,单引号,trailing comma
|
||||
|
||||
### TypeScript 配置
|
||||
|
||||
- 严格模式开启(`strict: true`)
|
||||
- `noImplicitAny` 设置为 `false`(允许隐式 any)
|
||||
- 未使用变量/参数会报错(`noUnusedLocals`, `noUnusedParameters`)
|
||||
- 模块解析模式:Bundler
|
||||
- 排除测试文件(`**/*.test.tsx`, `**/*.test.ts`)以避免类型检查
|
||||
|
||||
### 项目历史
|
||||
|
||||
近期重构(根据 git 历史):
|
||||
|
||||
- 移除了录制回放功能
|
||||
- 移除了测试页面
|
||||
- 精简为单页面时间戳工具
|
||||
- 将 storage 工具类重命名为 storageUtil
|
||||
@@ -1,72 +0,0 @@
|
||||
# Testing Tools 项目指南
|
||||
|
||||
本文件为 Gemini CLI 提供关于 **Testing Tools** 浏览器扩展项目的架构说明、开发规范和技术上下文。
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
- **名称**: Testing Tools
|
||||
- **核心框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||
- **前端技术栈**: React 19 (Functional Components + Hooks) + TypeScript
|
||||
- **UI 组件库**: Material UI (MUI) 7.x (深度定制 `sx` 属性)
|
||||
- **日期处理**: dayjs (配合 timezone 和 utc 插件)
|
||||
- **主要功能**: 提供时间戳转换、日期格式化等开发辅助工具。
|
||||
|
||||
## 2. 项目结构
|
||||
|
||||
```text
|
||||
├── .github/ # CI/CD 工作流
|
||||
├── .husky/ # Git Hooks (pre-commit linting)
|
||||
├── assets/ # 静态资源 (SVG 等)
|
||||
├── components/ # 复用 UI 组件
|
||||
├── entrypoints/ # 浏览器扩展入口点
|
||||
│ ├── background.ts # 后台 Service Worker 逻辑
|
||||
│ ├── content.ts # 内容脚本注入逻辑
|
||||
│ ├── popup/ # 扩展弹出层 (主要功能区)
|
||||
│ └── options/ # 扩展选项页面
|
||||
├── types/ # 全局 TypeScript 类型声明
|
||||
├── utils/ # 工具类 (存储、消息通信、日期处理封装)
|
||||
├── wxt.config.ts # WXT 框架与 Manifest 配置
|
||||
└── package.json # 依赖管理与脚本
|
||||
```
|
||||
|
||||
## 3. 开发规范与风格约定
|
||||
|
||||
### 3.1 UI 设计语言
|
||||
|
||||
- **极简主义 (Minimalist)**: 参考 Vercel 和 Apple 的设计语言。
|
||||
- **MUI 定制**:
|
||||
- 严禁使用 MUI 默认的粗犷边框和深重阴影。
|
||||
- 必须通过 `sx` 属性进行深度样式定制,去除 `notchedOutline`。
|
||||
- 偏好使用 `grey.50` 背景区分层级,使用 `borderRadius: 4` (大圆角)。
|
||||
- 交互反馈:禁用波纹效果 (`disableRipple`),移除默认阴影 (`disableElevation`)。
|
||||
- **布局**: 优先使用 `Stack` 和 `Box` 进行布局,确保自上而下的操作流顺畅。
|
||||
|
||||
### 3.2 技术选型惯例
|
||||
|
||||
- **日期转换**: 必须通过 `utils/dayjs.ts` 导出的实例进行,确保时区处理一致。
|
||||
- **状态管理**: 优先使用 React 原生 `useState` 和 `useMemo`。
|
||||
- **存储**: 使用 `utils/chromeStorage.ts` 封装的类型安全接口。
|
||||
- **通信**: 使用 `@webext-core/messaging` 进行 background 和 popup 之间的消息传递。
|
||||
|
||||
## 4. 关键指令
|
||||
|
||||
### 4.1 开发与调试
|
||||
|
||||
- `npm run dev`: 启动 Chrome 扩展开发模式。
|
||||
- `npm run dev:firefox`: 启动 Firefox 扩展开发模式。
|
||||
|
||||
### 4.2 构建与检查
|
||||
|
||||
- `npm run build`: 构建生产版本。
|
||||
- `npm run compile`: TypeScript 类型检查。
|
||||
- `npm run lint`: ESLint 代码风格检查。
|
||||
|
||||
## 5. 权限与清单 (Manifest)
|
||||
|
||||
- **核心权限**: `storage`, `unlimitedStorage`, `clipboardWrite`, `scripting`, `tabs`, `debugger`, `cookies`。
|
||||
- **宿主权限**: `<all_urls>` (用于在所有页面注入 content 脚本)。
|
||||
- **构建细节**: 生产环境构建使用 `terser` 压缩,并强制 `ascii_only` 以确保字符兼容性。
|
||||
|
||||
## 6. 维护者提示
|
||||
|
||||
在修改 `TimestampPage.tsx` 等核心页面时,应保持**逻辑层**(基于 dayjs 的转换算法)与**渲染层**(JSX/MUI 样式)的严格分离。
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Testing Tools
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,121 +1,127 @@
|
||||
# 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 签名部分,辅助验证令牌完整性.
|
||||
|
||||
### 🖼️ 二维码工具
|
||||
|
||||
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: WXT (Web Extension Toolkit)
|
||||
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||
- **前端**: React 19 + TypeScript
|
||||
- **UI 库**: Material UI
|
||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
||||
- **UI 组件**: Material UI (MUI) @7.x
|
||||
- **样式**: Emotion (Styled Components)
|
||||
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||
- **通信**: @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
|
||||
├── components/ # 可复用 React 组件
|
||||
├── config/ # 应用配置(路由、功能元数据、主题)
|
||||
│ ├── features.tsx # 功能定义与路由映射
|
||||
│ └── pageTheme.ts # 各功能页面的视觉风格配置
|
||||
├── entrypoints/ # 扩展程序入口点
|
||||
│ ├── popup/ # 点击图标弹出的主界面
|
||||
│ ├── options/ # 扩展程序设置页面
|
||||
│ ├── sidepanel/ # 浏览器侧边栏集成
|
||||
│ ├── background.ts # 后台 Service Worker
|
||||
│ └── content.ts # 网页注入脚本
|
||||
├── pages/ # 各功能模块的页面组件
|
||||
├── providers/ # 全局状态提供者 (Router, Snackbar 等)
|
||||
├── types/ # TypeScript 类型声明
|
||||
├── 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 compile` | 执行 TypeScript 类型检查 |
|
||||
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||
| `npm run test` | 运行单元测试 |
|
||||
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||
|
||||
### 2. 开发模式
|
||||
### 自动化流程
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run dev
|
||||
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run dev:firefox
|
||||
```
|
||||
|
||||
### 3. 构建生产版本
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run build
|
||||
|
||||
# Firefox 浏览器
|
||||
npm run build:firefox
|
||||
```
|
||||
|
||||
### 4. 打包分发
|
||||
|
||||
```bash
|
||||
# Chrome 浏览器
|
||||
npm run zip
|
||||
|
||||
# Firefox
|
||||
npm run zip:firefox
|
||||
```
|
||||
|
||||
### 5. 其他命令
|
||||
|
||||
```bash
|
||||
npm run compile # TypeScript 类型检查
|
||||
npm run lint # ESLint 代码检查
|
||||
```
|
||||
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||
|
||||
## 权限说明
|
||||
|
||||
扩展请求以下权限:
|
||||
本扩展根据功能需要申请了以下权限:
|
||||
|
||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
||||
- `debugger` - 调试器权限
|
||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
||||
- `storage`: 存储用户设置和工具配置.
|
||||
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||
- `scripting`: 在网页中执行清理脚本.
|
||||
- `cookies`: 管理和清理网站 Cookie.
|
||||
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||
- `clipboardWrite`: 提供一键复制功能.
|
||||
|
||||
## 主要依赖
|
||||
## 浏览器支持
|
||||
|
||||
- `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,93 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
|
||||
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
|
||||
|
||||
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
|
||||
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: string;
|
||||
tooltip?: string;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
|
||||
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
|
||||
}
|
||||
|
||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
text,
|
||||
tooltip = '复制',
|
||||
size = 'small',
|
||||
variant = 'ghost',
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
|
||||
|
||||
if (!text) {
|
||||
toast.error('无内容可复制');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await copyTextToClipboard(text);
|
||||
if (success) {
|
||||
toast.success('复制成功');
|
||||
setCopied(true);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 4. 将控制尺寸的类名标准化
|
||||
const sizeClasses = {
|
||||
small: 'h-8 w-8 text-xs',
|
||||
medium: 'h-10 w-10 text-sm',
|
||||
large: 'h-12 w-12 text-base',
|
||||
};
|
||||
|
||||
// 5. 映射 shadcn 的底层通用 Variant 类名
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={tooltip}
|
||||
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
sizeClasses[size],
|
||||
copied
|
||||
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
|
||||
: variantClasses[variant],
|
||||
className, // 允许外部直接传入 text-red-500 等覆盖样式
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||
) : (
|
||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* DecodeResultPaper
|
||||
*
|
||||
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
|
||||
* 提取了二者 decode 输出区完全一致的结构:
|
||||
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
|
||||
*
|
||||
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
|
||||
*/
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatFileSize } from '@/utils/base64Converter';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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 } = useTranslation('base64Converter');
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||
{/* 标题 */}
|
||||
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
|
||||
|
||||
{/* 可选预览内容(ImageMode 的图片) */}
|
||||
{children}
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div className="flex gap-4 mb-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('inferredMimeType')}: {mimeType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('decodedSize')}: {formatFileSize(blobSize)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 文件名输入 */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('decodedFileName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => onFileNameChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 下载按钮 */}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onDownload}
|
||||
disabled={!fileName.trim()}
|
||||
className="w-full rounded-lg font-bold"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('download')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.state.hasError && prevProps.children !== this.props.children) {
|
||||
this.setState({ hasError: false, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="mt-16 mx-auto max-w-md">
|
||||
<div className="p-6 text-center rounded-xl border border-red-200 bg-red-50">
|
||||
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-extrabold text-red-600 mb-2">糟糕,出了点问题</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg text-left max-h-[200px] overflow-auto">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-red-700">
|
||||
{this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={this.handleReset}
|
||||
className="rounded-lg font-bold bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新应用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
||||
*
|
||||
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
||||
*
|
||||
* @module GlobalSnackbar
|
||||
* @version 1.1.0
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // 方式一:受控组件
|
||||
* <GlobalSnackbar
|
||||
* message="操作成功"
|
||||
* open={isOpen}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* severity="success"
|
||||
* />
|
||||
*
|
||||
* // 方式二:Hook 方式 (局部状态)
|
||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
||||
* showMessage('Hello!', { severity: 'info' });
|
||||
*
|
||||
* // 方式三:Context 方式 (全局状态)
|
||||
* // 在根组件包裹 Provider
|
||||
* <SnackbarProvider>
|
||||
* <App />
|
||||
* </SnackbarProvider>
|
||||
*
|
||||
* // 在子组件中使用
|
||||
* const { showMessage } = useSnackbar();
|
||||
* showMessage('Global Message');
|
||||
* ```
|
||||
*/
|
||||
|
||||
import {
|
||||
JSX,
|
||||
useState,
|
||||
useRef,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
} from 'react';
|
||||
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Snackbar 消息严重程度类型
|
||||
* @description 决定 Alert 组件的颜色和图标
|
||||
* - success: 绿色,成功提示
|
||||
* - info: 蓝色,信息提示
|
||||
* - warning: 橙色,警告提示
|
||||
* - error: 红色,错误提示
|
||||
*/
|
||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的属性接口
|
||||
* @interface GlobalSnackbarProps
|
||||
*/
|
||||
export interface GlobalSnackbarProps {
|
||||
/** 消息内容,要显示的提示文本 */
|
||||
message: string;
|
||||
/** 是否显示 Snackbar */
|
||||
open: boolean;
|
||||
/** 关闭回调函数 */
|
||||
onClose: () => void;
|
||||
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
||||
severity?: SnackbarSeverity;
|
||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
||||
autoHideDuration?: number;
|
||||
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
||||
anchorOrigin?: {
|
||||
vertical: 'top' | 'bottom';
|
||||
horizontal: 'left' | 'center' | 'right';
|
||||
};
|
||||
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
||||
showAlert?: boolean;
|
||||
/** 是否隐藏 Alert 图标,默认 false */
|
||||
hideIcon?: boolean;
|
||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
||||
sx?: React.CSSProperties;
|
||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
||||
alertSx?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* showMessage 方法的选项配置
|
||||
* @interface SnackbarOptions
|
||||
*/
|
||||
export interface SnackbarOptions {
|
||||
/** 消息级别:success | info | warning | error */
|
||||
severity?: SnackbarSeverity;
|
||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
||||
autoHideDuration?: number;
|
||||
/** 是否隐藏 Alert 图标 */
|
||||
hideIcon?: boolean;
|
||||
/** 是否使用 Alert 组件包裹 */
|
||||
showAlert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbarState Hook 的返回值类型
|
||||
* @interface UseSnackbarStateResult
|
||||
*/
|
||||
export interface UseSnackbarStateResult {
|
||||
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
||||
snackbarProps: GlobalSnackbarProps;
|
||||
/** 显示消息的方法 */
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
/** 关闭消息的方法 */
|
||||
closeMessage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件的默认属性配置
|
||||
* @description 提供类型安全的默认值选择
|
||||
*/
|
||||
const defaultProps: Required<
|
||||
Pick<
|
||||
GlobalSnackbarProps,
|
||||
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
||||
>
|
||||
> = {
|
||||
severity: 'info',
|
||||
autoHideDuration: 2000,
|
||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
||||
showAlert: true,
|
||||
hideIcon: false,
|
||||
};
|
||||
|
||||
const severityConfig: Record<
|
||||
SnackbarSeverity,
|
||||
{ icon: React.ElementType; bgClass: string; textClass: string }
|
||||
> = {
|
||||
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
|
||||
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
|
||||
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
|
||||
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
|
||||
};
|
||||
|
||||
/**
|
||||
* GlobalSnackbar 组件
|
||||
*
|
||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
||||
*
|
||||
* @param {GlobalSnackbarProps} props - 组件属性
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export function GlobalSnackbar({
|
||||
message,
|
||||
open,
|
||||
onClose,
|
||||
severity = defaultProps.severity,
|
||||
autoHideDuration = defaultProps.autoHideDuration,
|
||||
showAlert = defaultProps.showAlert,
|
||||
hideIcon = defaultProps.hideIcon,
|
||||
}: GlobalSnackbarProps): JSX.Element | null {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && autoHideDuration > 0) {
|
||||
timerRef.current = setTimeout(() => {
|
||||
onClose();
|
||||
}, autoHideDuration);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [open, autoHideDuration, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const config = severityConfig[severity];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
{showAlert ? (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
|
||||
style={{ minWidth: '140px' }}
|
||||
>
|
||||
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
||||
<span className="text-xs font-bold">{message}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbarState - 消息提示的 Hook 方式
|
||||
*
|
||||
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
||||
* 适合在组件内部使用,无需额外的状态管理代码。
|
||||
*
|
||||
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
||||
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
||||
*
|
||||
* @description
|
||||
* - 自动管理 Snackbar 的显示/隐藏状态
|
||||
* - 支持链式调用 showMessage
|
||||
* - 合并初始选项和调用时选项
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
||||
* severity: 'info',
|
||||
* autoHideDuration: 3000,
|
||||
* });
|
||||
*
|
||||
* const handleSave = () => {
|
||||
* // 业务逻辑...
|
||||
* showMessage('保存成功!', { severity: 'success' });
|
||||
* };
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* <button onClick={handleSave}>保存</button>
|
||||
* <GlobalSnackbar {...snackbarProps} />
|
||||
* </>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
||||
// Snackbar 显示状态
|
||||
const [open, setOpen] = useState(false);
|
||||
// 当前显示的消息内容
|
||||
const [message, setMessage] = useState('');
|
||||
// 消息配置选项
|
||||
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
||||
|
||||
/**
|
||||
* 显示消息
|
||||
*
|
||||
* @param {string} newMessage - 要显示的消息文本
|
||||
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
||||
*
|
||||
* @description
|
||||
* - 合并初始选项和新的调用选项
|
||||
* - 新选项会覆盖初始选项
|
||||
*/
|
||||
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
||||
setMessage(newMessage);
|
||||
setOptions({ ...initialOptions, ...newOptions });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭消息
|
||||
*
|
||||
* @description
|
||||
* - 直接将 open 状态设置为 false
|
||||
*/
|
||||
const closeMessage = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
|
||||
if (reason === 'clickaway') return;
|
||||
closeMessage();
|
||||
};
|
||||
|
||||
/**
|
||||
* 传递给 GlobalSnackbar 组件的属性
|
||||
*
|
||||
* @description
|
||||
* - 组合当前状态和选项为完整的组件 props
|
||||
* - onClose 使用 handleClose 包装后的版本
|
||||
*/
|
||||
const snackbarProps: GlobalSnackbarProps = {
|
||||
message,
|
||||
open,
|
||||
onClose: handleClose,
|
||||
severity: options.severity,
|
||||
autoHideDuration: options.autoHideDuration,
|
||||
hideIcon: options.hideIcon,
|
||||
};
|
||||
|
||||
return {
|
||||
snackbarProps,
|
||||
showMessage,
|
||||
closeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Context & Provider ---
|
||||
|
||||
/**
|
||||
* Snackbar Context 的值类型定义
|
||||
*/
|
||||
interface SnackbarContextValue {
|
||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
||||
closeMessage: () => void;
|
||||
}
|
||||
|
||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SnackbarProvider 组件的 props 类型
|
||||
*/
|
||||
interface SnackbarProviderProps {
|
||||
children: ReactNode;
|
||||
initialOptions?: SnackbarOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* SnackbarProvider 组件
|
||||
*
|
||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
||||
*/
|
||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
|
||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
||||
|
||||
return (
|
||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
||||
{children}
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</SnackbarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
||||
*
|
||||
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration)
|
||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
||||
*
|
||||
* @description
|
||||
* 选项合并策略:
|
||||
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
|
||||
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
|
||||
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
|
||||
*/
|
||||
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
|
||||
const context = useContext(SnackbarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
||||
}
|
||||
|
||||
// 包装 showMessage 以支持 Hook 级别的 initialOptions
|
||||
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
|
||||
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
|
||||
// 优先级:callOptions > options
|
||||
const mergedOptions: SnackbarOptions = {
|
||||
...(options || {}),
|
||||
...(callOptions || {}),
|
||||
};
|
||||
context.showMessage(message, mergedOptions);
|
||||
};
|
||||
|
||||
return {
|
||||
...context,
|
||||
showMessage: wrappedShowMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export default GlobalSnackbar;
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Image, X } from 'lucide-react';
|
||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
/** 选中的文件 */
|
||||
selectedFile: File | null;
|
||||
/** 文件变更回调 */
|
||||
onFileChange: (file: File) => void;
|
||||
/** 清除文件回调 */
|
||||
onClearFile: () => void;
|
||||
/** 文件预览 URL */
|
||||
previewUrl: string;
|
||||
/** 预览 URL 变更回调 */
|
||||
onPreviewUrlChange: (url: string) => void;
|
||||
/** 是否正在拖拽 */
|
||||
dragging: boolean;
|
||||
/** 拖拽状态变更回调 */
|
||||
onDraggingChange: (dragging: boolean) => void;
|
||||
}
|
||||
|
||||
const ImageUploader = ({
|
||||
selectedFile,
|
||||
onFileChange,
|
||||
onClearFile,
|
||||
previewUrl,
|
||||
onPreviewUrlChange,
|
||||
dragging,
|
||||
onDraggingChange,
|
||||
}: ImageUploaderProps) => {
|
||||
const { t } = useLazyTranslation('qrCode');
|
||||
const { showMessage } = useSnackbar();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(file: File) => {
|
||||
onFileChange(file);
|
||||
onPreviewUrlChange(URL.createObjectURL(file));
|
||||
},
|
||||
[onFileChange, onPreviewUrlChange],
|
||||
);
|
||||
|
||||
const handleClearFile = () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
onClearFile();
|
||||
showMessage(t('qrCode:imageCleared'), {
|
||||
severity: 'success',
|
||||
autoHideDuration: 1000,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileChange(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
onDraggingChange(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
onDraggingChange(false);
|
||||
const droppedFile = e.dataTransfer.files?.[0];
|
||||
if (droppedFile) {
|
||||
handleFileChange(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听粘贴事件
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
try {
|
||||
handleFileChange(file);
|
||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
||||
} catch (error) {
|
||||
console.error('处理粘贴图片失败:', error);
|
||||
showMessage(t('qrCode:imagePasteError'), {
|
||||
severity: 'error',
|
||||
autoHideDuration: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [showMessage, handleFileChange, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||
dragging
|
||||
? 'border-green-600 bg-green-50'
|
||||
: selectedFile
|
||||
? 'border-green-600 bg-green-50/50'
|
||||
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
id="qr-code-upload"
|
||||
/>
|
||||
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
|
||||
{selectedFile ? (
|
||||
<div className="text-center w-full relative">
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Code Preview"
|
||||
className="max-w-full max-h-40 rounded-lg object-contain"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ClearIcon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearFile();
|
||||
}}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Image
|
||||
data-testid="ImageIcon"
|
||||
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
|
||||
/>
|
||||
<span className="block text-sm text-muted-foreground mb-1">
|
||||
{t('qrCode:clickToUpload')}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('qrCode:supportFormats')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
resetKey?: string | number;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级错误边界组件:捕获子组件树中的 JavaScript 错误
|
||||
* 完美适配 shadcn/ui 语义化主题与暗黑模式
|
||||
*/
|
||||
export class PageErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error in page:', error, errorInfo);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||
this.setState({ hasError: false, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
|
||||
{/*
|
||||
1. 适配暗黑模式的容器设计:
|
||||
不再使用 border-red-200 / bg-red-50,改用标准的 border-destructive/20 和 bg-destructive/5,
|
||||
并在黑夜模式下会自动转为深红底色,绝不刺眼。
|
||||
*/}
|
||||
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
||||
{/* 2. 状态符号改用标准的 text-destructive 语义色 */}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">该功能运行异常</h3>
|
||||
<p className="text-xs text-muted-foreground mb-5">
|
||||
该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。
|
||||
</p>
|
||||
|
||||
{/* 3. 错误日志展示:使用与 shadcn 贴合的深色代码块包裹 */}
|
||||
{this.state.error && (
|
||||
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
||||
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||
{this.state.error.stack || this.state.error.toString()}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
4. 严谨调用 shadcn 原子 Button:
|
||||
去掉全部手动指定的红底白字类名,直接启用 variant="destructive"。
|
||||
它会自动处理 hover 颜色变化、暗黑模式切换以及无障碍高亮边框。
|
||||
*/}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={this.handleRetry}
|
||||
className="font-medium shadow-sm"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
重新尝试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default PageErrorBoundary;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { getEntryPointType } from '@/config/features';
|
||||
import { cn } from '@/lib/utils'; // shadcn 核心类名合并工具
|
||||
|
||||
export interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** 要显示的图标组件 */
|
||||
icon: ReactNode;
|
||||
/**
|
||||
* 图标的颜色,支持:
|
||||
* 1. Tailwind 颜色类名 (如 'text-blue-500', 'text-primary') -> 推荐
|
||||
* 2. 原生颜色值 (如 '#3b82f6')
|
||||
*/
|
||||
iconColor?: string;
|
||||
/** 主标题文本 */
|
||||
title: string;
|
||||
/** 副标题文本(可选) */
|
||||
subtitle?: string;
|
||||
/** 在标题右侧显示的徽章/标签组件(可选) */
|
||||
badge?: ReactNode;
|
||||
/** 覆盖图标容器的类名 */
|
||||
iconClassName?: string;
|
||||
/** 覆盖主标题的类名 */
|
||||
titleClassName?: string;
|
||||
/** 覆盖副标题的类名 */
|
||||
subtitleClassName?: string;
|
||||
}
|
||||
|
||||
export default function PageHeader({
|
||||
icon,
|
||||
iconColor = 'text-blue-500', // 默认改用类名,若需保持 Hex 可写 "#3b82f6"
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
iconClassName,
|
||||
titleClassName,
|
||||
subtitleClassName,
|
||||
className,
|
||||
...props
|
||||
}: PageHeaderProps) {
|
||||
const entryPointType = useMemo(() => getEntryPointType(), []);
|
||||
|
||||
// 扩展环境判断:如果是 popup 形式则不渲染头部
|
||||
if (entryPointType === 'popup') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 判断传入的是否是 Hex/RGB 等原生颜色值
|
||||
const isRawColor =
|
||||
iconColor.startsWith('#') || iconColor.startsWith('rgb') || iconColor.startsWith('hsl');
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 mb-6', className)} {...props}>
|
||||
{/* 图标容器 */}
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex items-center justify-center shrink-0',
|
||||
// 如果不是原生颜色,直接当作 Tailwind 类名注入
|
||||
!isRawColor && iconColor,
|
||||
iconClassName,
|
||||
)}
|
||||
style={
|
||||
isRawColor
|
||||
? {
|
||||
color: iconColor,
|
||||
// 使用 CSS inline 变量或 color-mix 安全处理透明度,不再暴力拼接 "15"
|
||||
backgroundColor: `color-mix(in srgb, ${iconColor} 8%, transparent)`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* 确保图标大小可控,通过子元素选择器约束 SVG 宽高 */}
|
||||
<div className="[&>svg]:h-5 [&>svg]:w-5">{icon}</div>
|
||||
</div>
|
||||
|
||||
{/* 标题区域 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
{/* 标题行(含徽章) */}
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<h1
|
||||
className={cn(
|
||||
'text-base font-extrabold tracking-tight leading-tight text-foreground truncate',
|
||||
titleClassName,
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{badge && <div className="shrink-0">{badge}</div>}
|
||||
</div>
|
||||
|
||||
{/* 副标题 - 使用 p 标签(block)保证换行 */}
|
||||
{subtitle && (
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-semibold text-muted-foreground truncate',
|
||||
subtitleClassName,
|
||||
)}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PageSkeleton 组件 - 页面加载骨架屏
|
||||
*
|
||||
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
|
||||
* 避免白屏闪烁,减少布局偏移
|
||||
*/
|
||||
interface PageSkeletonProps {
|
||||
/** 骨架屏类型 */
|
||||
variant?: 'dashboard' | 'tool';
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘卡片骨架屏
|
||||
*/
|
||||
function DashboardCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-5 h-[100px]">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
|
||||
<div>
|
||||
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
|
||||
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具页面骨架屏
|
||||
*/
|
||||
function ToolPageSkeleton() {
|
||||
return (
|
||||
<div className="p-5">
|
||||
{/* 标题区域 */}
|
||||
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
|
||||
|
||||
{/* 控制栏 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="flex-1" />
|
||||
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面加载骨架屏
|
||||
*
|
||||
* @param props - PageSkeletonProps
|
||||
* @returns 骨架屏 JSX 元素
|
||||
*/
|
||||
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
|
||||
if (variant === 'tool') {
|
||||
return <ToolPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<DashboardCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageSkeleton.displayName = 'PageSkeleton';
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
|
||||
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** 二维码 Data URL */
|
||||
qrCodeDataUrl: string;
|
||||
/** 下载回调 */
|
||||
onDownload: () => void;
|
||||
/** 复制回调 */
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
const QrCodePreview = ({
|
||||
qrCodeDataUrl,
|
||||
onDownload,
|
||||
onCopy,
|
||||
className,
|
||||
...props
|
||||
}: QrCodePreviewProps) => {
|
||||
const { t } = useLazyTranslation('qrCode');
|
||||
|
||||
// 空状态下的虚线骨架屏
|
||||
if (!qrCodeDataUrl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground text-center">{t('qrCode:qrCodeWillShow')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-input rounded-xl p-6 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-center w-full max-w-xs">
|
||||
{/*
|
||||
2. 二维码容器适配:
|
||||
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||
通过裹一层 bg-white 和 p-3,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||
*/}
|
||||
<div className="p-3 bg-white rounded-lg shadow-sm border border-border/40">
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="QR Code Preview"
|
||||
className="w-56 h-56 max-w-full object-contain block animate-in fade-in duration-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. 按钮群全面向 shadcn 官方 Button 视觉规范对齐 */}
|
||||
<div className="flex w-full gap-2 mt-5">
|
||||
{/* 下载按钮:使用标准的次要按钮风格 (Outline) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md border border-input bg-background shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="truncate">{t('qrCode:downloadButton')}</span>
|
||||
</button>
|
||||
|
||||
{/* 复制按钮:使用标准的主要行动按钮风格 (Default) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="flex-1 inline-flex h-9 items-center justify-center gap-2 px-3 text-sm font-medium rounded-md bg-primary text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span className="truncate">{t('qrCode:copyQrButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QrCodePreview;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
|
||||
|
||||
export default function RouterContainer() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
|
||||
// 2. 稳定的动态动画类名映射
|
||||
const animationClass = useMemo(() => {
|
||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||
}, [currentPage]);
|
||||
|
||||
const entryPointType = useMemo(() => {
|
||||
return getEntryPointType();
|
||||
}, []);
|
||||
|
||||
// 骨架屏加载状态守卫
|
||||
if (!isLoaded) {
|
||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||
}
|
||||
|
||||
// 3. 严格的路由查找与类型安全的组件分发
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
|
||||
className={cn(
|
||||
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||
'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
|
||||
animationClass,
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||
>
|
||||
<PageErrorBoundary resetKey={currentPage}>
|
||||
{/*
|
||||
4. 路由防御拦截:
|
||||
如果组件存在则正常流式渲染,如果由于版本更迭或非法路径导致找不到对应组件,
|
||||
渲染一个优雅且符合 shadcn 风格的中性 404 提示页,而不是死白屏。
|
||||
*/}
|
||||
{MatchedComponent ? (
|
||||
<MatchedComponent />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">页面未找到</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||
该功能在当前运行环境({entryPointType})下不可用或已被移除。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageErrorBoundary>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||
|
||||
export interface SwitchOption<T extends string | number = string> {
|
||||
value: T;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
// 2. 移除内联 sx,继承标准 HTML 属性,并使用标准的类名注入机制
|
||||
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
|
||||
React.HTMLAttributes<HTMLDivElement>,
|
||||
'onChange'
|
||||
> {
|
||||
value: T;
|
||||
options: SwitchOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
buttonClassName?: string; // 替换原有的 buttonSx
|
||||
}
|
||||
|
||||
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
size = 'medium',
|
||||
className,
|
||||
buttonClassName,
|
||||
...props
|
||||
}: SwitchButtonGroupProps<T>) {
|
||||
// 3. 将尺寸和高度、内边距等整体对齐,保证按钮和背景容器成比例缩放
|
||||
const sizeClasses = {
|
||||
small: 'text-xs h-8 px-2 py-1 rounded-md',
|
||||
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
|
||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||
};
|
||||
|
||||
const containerPadding = size === 'large' ? 'p-1' : 'p-1';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// 将默认布局设计得更为通用(去掉一刀切的 mb-4,由外部控制布局空间)
|
||||
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground',
|
||||
containerPadding,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
// 4. 完美继承 shadcn 的 Tabs 交互和动效微调
|
||||
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
sizeClasses[size],
|
||||
isSelected
|
||||
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
|
||||
: 'hover:bg-background/50 hover:text-foreground/80',
|
||||
buttonClassName,
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||
import { Copy, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
||||
|
||||
export type ValidateRule = {
|
||||
validator: (value: string) => boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ToolbarAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
position?: 'top' | 'bottom';
|
||||
type?: 'primary' | 'default' | 'danger';
|
||||
disabled?: boolean | ((value: string) => boolean);
|
||||
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||
};
|
||||
|
||||
export interface TextInputAreaProps extends Omit<
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'onChange'
|
||||
> {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
|
||||
/** 值变化回调,返回最新的字符串内容 */
|
||||
onChange?: (value: string) => void;
|
||||
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
showCount?: boolean;
|
||||
showClear?: boolean;
|
||||
allowCopy?: boolean;
|
||||
rules?: ValidateRule[];
|
||||
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||
actions?: ToolbarAction[];
|
||||
topExtra?: React.ReactNode;
|
||||
title?: string;
|
||||
externalError?: string;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
||||
function ActionButton({
|
||||
action,
|
||||
value,
|
||||
globalDisabled,
|
||||
onAction,
|
||||
}: {
|
||||
action: ToolbarAction;
|
||||
value: string;
|
||||
globalDisabled: boolean;
|
||||
onAction: (action: ToolbarAction) => void;
|
||||
}) {
|
||||
const isBtnDisabled =
|
||||
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
default:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction(action)}
|
||||
disabled={isBtnDisabled || globalDisabled}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
variantClasses[action.type || 'default'],
|
||||
)}
|
||||
>
|
||||
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||
{action.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||
const {
|
||||
value: controlledValue,
|
||||
defaultValue = '',
|
||||
onChange,
|
||||
placeholder: placeholderProp,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
autoFocus = false,
|
||||
minRows = 4,
|
||||
maxRows = 12,
|
||||
maxLength,
|
||||
className,
|
||||
showCount = false,
|
||||
showClear = true,
|
||||
allowCopy = false,
|
||||
rules = [],
|
||||
validateTrigger = 'onAction',
|
||||
actions = [],
|
||||
topExtra,
|
||||
title,
|
||||
externalError,
|
||||
onClear,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
||||
|
||||
const isControlled = controlledValue !== undefined;
|
||||
const value = isControlled ? controlledValue : internalValue;
|
||||
const displayError = externalError ?? error;
|
||||
|
||||
// 双向合并 ref 指针
|
||||
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
||||
|
||||
// 1. 高性能的动态高度自适应计算
|
||||
const adjustHeight = useCallback(() => {
|
||||
const textArea = internalRef.current;
|
||||
if (!textArea) return;
|
||||
|
||||
// 重置高度计算
|
||||
textArea.style.height = 'auto';
|
||||
|
||||
const computedMin = minRows * 24; // 每行粗略按 24px 计算
|
||||
const computedMax = maxRows * 24;
|
||||
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
|
||||
|
||||
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
|
||||
}, [minRows, maxRows]);
|
||||
|
||||
// 当数值改变时自适应扩展
|
||||
React.useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [value, adjustHeight]);
|
||||
|
||||
const validate = useCallback(
|
||||
(val: string, trigger?: string): boolean => {
|
||||
if (validateTrigger !== trigger && trigger) return true;
|
||||
for (const rule of rules) {
|
||||
if (!rule.validator(val)) {
|
||||
setError(rule.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
setError('');
|
||||
return true;
|
||||
},
|
||||
[rules, validateTrigger],
|
||||
);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newVal = e.target.value;
|
||||
if (maxLength && newVal.length > maxLength) {
|
||||
const msg = t('charCount', { count: maxLength });
|
||||
setError(msg);
|
||||
toast.warning(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isControlled) setInternalValue(newVal);
|
||||
onChange?.(newVal);
|
||||
|
||||
if (error) setError('');
|
||||
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||
};
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (!isControlled) setInternalValue('');
|
||||
onChange?.('');
|
||||
setError('');
|
||||
internalRef.current?.focus();
|
||||
toast.success('已清空内容');
|
||||
onClear?.();
|
||||
}, [isControlled, onChange, onClear]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success('复制成功');
|
||||
} catch {
|
||||
setError('复制失败');
|
||||
toast.error('复制失败');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: ToolbarAction) => {
|
||||
const isDisabled =
|
||||
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
||||
if (isDisabled || disabled) return;
|
||||
|
||||
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
||||
|
||||
action.onClick(value, {
|
||||
clear: handleClear,
|
||||
setError,
|
||||
});
|
||||
},
|
||||
[value, disabled, validate, validateTrigger, handleClear],
|
||||
);
|
||||
|
||||
const topActions = actions.filter((a) => a.position !== 'bottom');
|
||||
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
||||
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
||||
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
||||
{hasTopBar && (
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
||||
{topExtra}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{topActions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
action={action}
|
||||
value={value}
|
||||
globalDisabled={disabled}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
{showCount && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{value.length}
|
||||
{maxLength ? ` / ${maxLength}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
|
||||
displayError &&
|
||||
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={internalRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
readOnly={readOnly}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
{hasBottomBar && (
|
||||
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
|
||||
{/* 左侧自定义动作 */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{bottomActions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.key}
|
||||
action={action}
|
||||
value={value}
|
||||
globalDisabled={disabled}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧系统按钮组 */}
|
||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
{allowCopy && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
aria-label={t('textInputArea.copyContent')}
|
||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-background/80 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{showClear && value && !disabled && !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
aria-label={t('textInputArea.clear')}
|
||||
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{displayError && (
|
||||
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
{displayError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TextInputArea.displayName = 'TextInputArea';
|
||||
|
||||
export default TextInputArea;
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
History,
|
||||
Monitor,
|
||||
Moon,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { cn } from '@/lib/utils'; // 1. 引入 shadcn 核心工具函数
|
||||
|
||||
// 常量配置抽取(无需写在全局变量或 styles 对象里)
|
||||
const SEARCH_HISTORY_LIMIT = 10;
|
||||
const SEARCH_HISTORY_DISPLAY = 5;
|
||||
|
||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||
const { currentPage, goBack, navigateTo } = useRouter();
|
||||
const { mode, setMode } = useThemeMode();
|
||||
const { t, i18n } = useTranslation(['common', 'features']);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleOpenInTab = async () => {
|
||||
await openExtensionPage('popup.html', { mode: 'tab' });
|
||||
window.close();
|
||||
};
|
||||
|
||||
// 2. 健壮的 Click Outside 逻辑:点击空白处收起搜索框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowResults(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 从 Chrome Storage 异步初始化历史记录
|
||||
useEffect(() => {
|
||||
storageUtil
|
||||
.get('app/searchHistory', [])
|
||||
.then((history) => {
|
||||
if (history) setSearchHistory(history);
|
||||
})
|
||||
.catch((err) => console.error('加载搜索历史失败:', err));
|
||||
}, []);
|
||||
|
||||
// 3. 模糊搜索匹配(移除了无意义的 dashboard 干扰项)
|
||||
const searchResults = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
if (!query) return [];
|
||||
return FEATURES.filter((f) => {
|
||||
if (f.key === 'dashboard') return false;
|
||||
return (
|
||||
t(f.labelKey).toLowerCase().includes(query) ||
|
||||
t(f.descriptionKey).toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [searchQuery, t]);
|
||||
|
||||
const displayedHistory = useMemo(() => {
|
||||
if (searchQuery.trim()) return [];
|
||||
return searchHistory.slice(0, SEARCH_HISTORY_DISPLAY);
|
||||
}, [searchHistory, searchQuery]);
|
||||
|
||||
// 新增/持久化历史记录
|
||||
const saveToHistory = async (query: string) => {
|
||||
if (!query.trim()) return;
|
||||
const nextHistory = [query, ...searchHistory.filter((h) => h !== query)].slice(
|
||||
0,
|
||||
SEARCH_HISTORY_LIMIT,
|
||||
);
|
||||
setSearchHistory(nextHistory);
|
||||
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const handleSelectFeature = (feature: FeatureConfig) => {
|
||||
navigateTo(feature.key);
|
||||
saveToHistory(t(feature.labelKey));
|
||||
setSearchQuery('');
|
||||
setShowResults(false);
|
||||
};
|
||||
|
||||
const toggleLanguage = async () => {
|
||||
const currentLng = normalizeLanguage(i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLng);
|
||||
const nextLng = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
await i18n.changeLanguage(nextLng);
|
||||
await storageUtil.set('app/language', nextLng);
|
||||
};
|
||||
|
||||
const cycleThemeMode = () => {
|
||||
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||
setMode(nextMap[mode]);
|
||||
};
|
||||
|
||||
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
||||
|
||||
// 4. 健壮的键盘导航交互
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0) {
|
||||
if (searchQuery.trim()) {
|
||||
handleSelectFeature(searchResults[selectedIndex]);
|
||||
} else {
|
||||
const selectedQuery = displayedHistory[selectedIndex];
|
||||
setSearchQuery(selectedQuery);
|
||||
setSelectedIndex(-1);
|
||||
const matched = FEATURES.find(
|
||||
(f) => f.key !== 'dashboard' && t(f.labelKey) === selectedQuery,
|
||||
);
|
||||
if (matched) handleSelectFeature(matched);
|
||||
}
|
||||
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||
handleSelectFeature(searchResults[0]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowResults(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const isDashboard = currentPage === 'dashboard';
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
||||
{/* 左侧:返回按钮区 */}
|
||||
<div className="flex w-10 items-center justify-start">
|
||||
{!isDashboard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
aria-label={t('common:buttons.back')}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中间:搜索容器 */}
|
||||
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder={t('common:buttons.search')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setShowResults(true);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setShowResults(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={t('common:buttons.search')}
|
||||
className="w-full h-9 pl-9 pr-8 text-sm rounded-md border border-input bg-muted/50 transition-all placeholder:text-muted-foreground focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
aria-label={t('common:buttons.clearSearch')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 动态联想结果卡片 */}
|
||||
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover text-popover-foreground rounded-md shadow-md border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
<ul role="listbox" className="p-1">
|
||||
{searchQuery.trim() ? (
|
||||
searchResults.length > 0 ? (
|
||||
searchResults.map((feature, index) => (
|
||||
<li
|
||||
key={feature.key}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => handleSelectFeature(feature)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
||||
selectedIndex === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm bg-muted text-muted-foreground">
|
||||
{feature.icon && <feature.icon className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{t(feature.labelKey)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t(feature.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
{t('common:buttons.noResults')}
|
||||
</li>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="px-2.5 py-1.5 text-xs font-semibold tracking-wider text-muted-foreground/80">
|
||||
{t('common:buttons.recentSearch')}
|
||||
</div>
|
||||
{displayedHistory.map((item, index) => (
|
||||
<li
|
||||
key={item}
|
||||
role="option"
|
||||
aria-selected={selectedIndex === index}
|
||||
onClick={() => {
|
||||
setSearchQuery(item);
|
||||
setSelectedIndex(-1);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2.5 py-2 rounded-sm cursor-pointer text-sm transition-colors',
|
||||
selectedIndex === index
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<History className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作区 */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<IconButton onClick={toggleLanguage} title={t('common:buttons.toggleLanguage')}>
|
||||
<Globe className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
|
||||
<ThemeIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</IconButton>
|
||||
<IconButton onClick={onOpenOptions} title={t('common:buttons.settings')}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
|
||||
function IconButton({
|
||||
children,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||
|
||||
describe('DecodeResultPaper 组件', () => {
|
||||
const defaultProps = {
|
||||
title: 'decodedFileOutput',
|
||||
mimeType: 'image/png',
|
||||
blobSize: 1024,
|
||||
fileName: 'decoded.png',
|
||||
onFileNameChange: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 MIME 类型信息', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应通过 formatFileSize 渲染文件大小', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
|
||||
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染文件名输入框', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染下载按钮', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: 'download' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染 children 内容', () => {
|
||||
render(
|
||||
<DecodeResultPaper {...defaultProps}>
|
||||
<div data-testid="preview">预览内容</div>
|
||||
</DecodeResultPaper>,
|
||||
);
|
||||
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||
expect(screen.getByText('预览内容')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('修改文件名时应调用 onFileNameChange', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
const input = screen.getByDisplayValue('decoded.png');
|
||||
fireEvent.change(input, { target: { value: 'new-name.png' } });
|
||||
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
|
||||
});
|
||||
|
||||
it('点击下载按钮时应调用 onDownload', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'download' }));
|
||||
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('按钮状态', () => {
|
||||
it('文件名为空时下载按钮应禁用', () => {
|
||||
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||
expect(screen.getByRole('button', { name: 'download' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('文件名不为空时下载按钮应启用', () => {
|
||||
render(<DecodeResultPaper {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: 'download' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
|
||||
// 用于触发错误的测试子组件
|
||||
function ThrowError({ message }: { message: string }): never {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
// 正常渲染的子组件
|
||||
function NormalComponent({ text }: { text: string }) {
|
||||
return <div data-testid="normal-content">{text}</div>;
|
||||
}
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
beforeEach(() => {
|
||||
// 抑制测试中故意抛出的错误日志
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('正常渲染子组件', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent text="正常内容" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('正常内容');
|
||||
});
|
||||
|
||||
it('子组件抛出错误时显示错误 UI', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="测试错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
expect(screen.getByText(/测试错误/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('children 变化时重置错误状态', async () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="初始错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
|
||||
// 切换到正常子组件
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<NormalComponent text="恢复后的内容" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('normal-content')).toHaveTextContent('恢复后的内容');
|
||||
});
|
||||
|
||||
expect(screen.queryByText('糟糕,出了点问题')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('相同的 children 不重置错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="相同子组件错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
|
||||
// 用相同的 children rerender
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="相同子组件错误" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('糟糕,出了点问题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('错误 UI 包含刷新按钮', () => {
|
||||
const reloadMock = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { reload: reloadMock },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ThrowError message="按钮测试" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /刷新应用/ });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
|
||||
refreshButton.click();
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import {
|
||||
GlobalSnackbar,
|
||||
type GlobalSnackbarProps,
|
||||
SnackbarProvider,
|
||||
useSnackbar,
|
||||
useSnackbarState,
|
||||
} from '@/components/GlobalSnackbar';
|
||||
|
||||
describe('GlobalSnackbar 组件系统', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const defaultProps: GlobalSnackbarProps = {
|
||||
message: '测试消息',
|
||||
open: true,
|
||||
onClose: mockOnClose,
|
||||
};
|
||||
|
||||
describe('GlobalSnackbar UI 渲染', () => {
|
||||
it('应渲染消息内容', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} />);
|
||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
||||
// 验证是否包含消息文本
|
||||
const alertElement = screen.getByText('测试消息');
|
||||
expect(alertElement).toBeInTheDocument();
|
||||
// 验证父元素有正确的样式类
|
||||
const parent = alertElement.parentElement;
|
||||
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
|
||||
});
|
||||
|
||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
||||
// 图标使用 lucide-react 的 svg 元素
|
||||
const icon = document.querySelector('svg');
|
||||
expect(icon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应根据 severity 应用不同的样式', () => {
|
||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
||||
const message = screen.getByText('测试消息');
|
||||
const parent = message.parentElement;
|
||||
expect(parent).toHaveClass('bg-red-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSnackbarState Hook 逻辑', () => {
|
||||
it('应返回初始状态', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
expect(result.current.snackbarProps.message).toBe('');
|
||||
});
|
||||
|
||||
it('showMessage 应更新状态', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
|
||||
act(() => {
|
||||
result.current.showMessage('新消息');
|
||||
});
|
||||
|
||||
expect(result.current.snackbarProps.open).toBe(true);
|
||||
expect(result.current.snackbarProps.message).toBe('新消息');
|
||||
});
|
||||
|
||||
it('closeMessage 应关闭消息', () => {
|
||||
const { result } = renderHook(() => useSnackbarState());
|
||||
|
||||
act(() => {
|
||||
result.current.showMessage('消息');
|
||||
});
|
||||
act(() => {
|
||||
result.current.closeMessage();
|
||||
});
|
||||
|
||||
expect(result.current.snackbarProps.open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSnackbar Context Hook 优先级', () => {
|
||||
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
|
||||
|
||||
// 1. 测试 Hook Options 覆盖 Provider Options
|
||||
act(() => {
|
||||
result.current.showMessage('消息 1');
|
||||
});
|
||||
|
||||
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
||||
|
||||
// 2. 测试 Call Options 覆盖 Hook Options
|
||||
act(() => {
|
||||
result.current.showMessage('消息 2', { severity: 'error' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import ImageUploader from '@/components/ImageUploader';
|
||||
|
||||
// 模拟 URL API
|
||||
const mockCreateObjectURL = vi.fn();
|
||||
const mockRevokeObjectURL = vi.fn();
|
||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||
|
||||
// 模拟 showMessage
|
||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
||||
useSnackbar: () => ({
|
||||
showMessage: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ImageUploader 组件', () => {
|
||||
const mockOnFileChange = vi.fn();
|
||||
const mockOnClearFile = vi.fn();
|
||||
const mockOnPreviewUrlChange = vi.fn();
|
||||
const mockOnDraggingChange = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
selectedFile: null,
|
||||
onFileChange: mockOnFileChange,
|
||||
onClearFile: mockOnClearFile,
|
||||
previewUrl: '',
|
||||
onPreviewUrlChange: mockOnPreviewUrlChange,
|
||||
dragging: false,
|
||||
onDraggingChange: mockOnDraggingChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateObjectURL.mockReturnValue('blob:test-url');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('当没有选中文件时应显示上传提示', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByText('qrCode:clickToUpload')).toBeInTheDocument();
|
||||
expect(screen.getByText('qrCode:supportFormats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当没有选中文件时应显示 ImageIcon', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
expect(screen.getByTestId('ImageIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示文件预览', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByText('test.png')).toBeInTheDocument();
|
||||
expect(screen.getByText('qrCode:clickToChange')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当选中文件时应显示预览图片', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', 'blob:test-url');
|
||||
});
|
||||
|
||||
it('当选中文件时应显示清除按钮', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
expect(screen.getByTestId('ClearIcon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应包含隐藏的文件输入框', () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('type', 'file');
|
||||
expect(input).toHaveAttribute('accept', 'image/*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文件选择交互', () => {
|
||||
it('选择文件时应调用 onFileChange 和 onPreviewUrlChange', async () => {
|
||||
render(<ImageUploader {...defaultProps} />);
|
||||
const input = document.getElementById('qr-code-upload') as HTMLInputElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [mockFile] } });
|
||||
});
|
||||
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockFile);
|
||||
expect(mockOnPreviewUrlChange).toHaveBeenCalledWith('blob:test-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe('拖拽交互', () => {
|
||||
it('拖拽进入时应调用 onDraggingChange(true)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragOver(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('拖拽离开时应调用 onDraggingChange(false)', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
|
||||
fireEvent.dragLeave(dropzone);
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('放置文件时应调用 onFileChange 和 onPreviewUrlChange', () => {
|
||||
const { container } = render(<ImageUploader {...defaultProps} />);
|
||||
const dropzone = container.firstChild as HTMLElement;
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true });
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: [mockFile],
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dropEvent, 'preventDefault', {
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
fireEvent(dropzone, dropEvent);
|
||||
|
||||
expect(mockOnDraggingChange).toHaveBeenCalledWith(false);
|
||||
expect(mockOnFileChange).toHaveBeenCalledWith(mockFile);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清除文件功能', () => {
|
||||
it('点击清除按钮时应调用 onClearFile', () => {
|
||||
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
|
||||
render(
|
||||
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByTestId('ClearIcon').closest('button')!;
|
||||
fireEvent.click(clearButton);
|
||||
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url');
|
||||
expect(mockOnClearFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('清除文件时应撤销预览 URL', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
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,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import PageHeader, { type PageHeaderProps } from '@/components/PageHeader';
|
||||
|
||||
vi.mock('@/config/features', () => ({
|
||||
getEntryPointType: vi.fn(() => 'sidepanel'),
|
||||
}));
|
||||
|
||||
describe('PageHeader 组件系统', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const defaultProps: PageHeaderProps = {
|
||||
icon: <span data-testid="test-icon">⏰</span>,
|
||||
title: '时间戳转换',
|
||||
subtitle: 'Unix 毫秒数转换与格式化',
|
||||
};
|
||||
|
||||
describe('PageHeader UI 渲染', () => {
|
||||
it('应渲染页面标题栏&副标题', () => {
|
||||
render(<PageHeader {...defaultProps} />);
|
||||
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染图标', () => {
|
||||
render(<PageHeader {...defaultProps} />);
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染自定义图标&图标颜色', () => {
|
||||
render(
|
||||
<PageHeader
|
||||
{...defaultProps}
|
||||
icon={<span data-testid="custom-icon">X</span>}
|
||||
iconColor="#FF0000"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应默认使用蓝色作为 primary 色', () => {
|
||||
render(<PageHeader {...defaultProps} />);
|
||||
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
||||
expect(iconContainer).toHaveClass('text-blue-500');
|
||||
});
|
||||
|
||||
it('应渲染 badge 组件', () => {
|
||||
const badge = <span data-testid="test-badge">New</span>;
|
||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应支持自定义 iconClassName', () => {
|
||||
render(<PageHeader {...defaultProps} iconClassName="custom-icon-class" />);
|
||||
const iconContainer = screen.getByTestId('test-icon').parentElement?.parentElement;
|
||||
expect(iconContainer).toHaveClass('custom-icon-class');
|
||||
});
|
||||
|
||||
it('应支持自定义 titleClassName', () => {
|
||||
render(<PageHeader {...defaultProps} titleClassName="custom-title-class" />);
|
||||
const title = screen.getByText('时间戳转换');
|
||||
expect(title).toHaveClass('custom-title-class');
|
||||
});
|
||||
|
||||
it('应支持自定义 subtitleClassName', () => {
|
||||
render(<PageHeader {...defaultProps} subtitleClassName="custom-subtitle-class" />);
|
||||
const subtitle = screen.getByText('Unix 毫秒数转换与格式化');
|
||||
expect(subtitle).toHaveClass('custom-subtitle-class');
|
||||
});
|
||||
|
||||
it('应支持自定义 className', () => {
|
||||
const { container } = render(<PageHeader {...defaultProps} className="custom-page-header" />);
|
||||
const outerElement = container.firstChild;
|
||||
expect(outerElement).toHaveClass('custom-page-header');
|
||||
});
|
||||
|
||||
it('无副标题时不渲染副标题区域', () => {
|
||||
const { container } = render(<PageHeader icon={defaultProps.icon} title="仅标题" />);
|
||||
const subtitles = container.querySelectorAll('.text-muted-foreground');
|
||||
expect(subtitles.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageHeader 入口点隐藏', () => {
|
||||
it('popup 模式下应返回 null', async () => {
|
||||
const { getEntryPointType } = await import('@/config/features');
|
||||
vi.mocked(getEntryPointType).mockReturnValue('popup');
|
||||
|
||||
const { container } = render(<PageHeader {...defaultProps} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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('qrCode:qrCodeWillShow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示二维码图片', () => {
|
||||
const testDataUrl = 'data:image/png;base64,test123';
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl={testDataUrl}
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByAltText('QR Code Preview');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', testDataUrl);
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示下载按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('qrCode:downloadButton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 有值时应显示复制按钮', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('qrCode:copyQrButton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('当 qrCodeDataUrl 为空时不应显示操作按钮', () => {
|
||||
render(<QrCodePreview qrCodeDataUrl="" onDownload={mockOnDownload} onCopy={mockOnCopy} />);
|
||||
expect(screen.queryByText('qrCode:downloadButton')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('qrCode:copyQrButton')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击下载按钮时应调用 onDownload 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('qrCode:downloadButton'));
|
||||
expect(mockOnDownload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击复制按钮时应调用 onCopy 回调', () => {
|
||||
render(
|
||||
<QrCodePreview
|
||||
qrCodeDataUrl="data:image/png;base64,test"
|
||||
onDownload={mockOnDownload}
|
||||
onCopy={mockOnCopy}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('qrCode:copyQrButton'));
|
||||
expect(mockOnCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('RouterContainer 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<SnackbarProvider>
|
||||
<RouterProvider>{ui}</RouterProvider>
|
||||
</SnackbarProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||
mockRouterValue.isLoaded = false;
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
// 骨架屏使用 animate-pulse 类
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('动画类测试', () => {
|
||||
it('在 dashboard 页面应应用 dashboard 动画类', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<RouterContainer />);
|
||||
const box = document.querySelector('.page-transition-dashboard');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在非 dashboard 页面应应用 enter 动画类', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<RouterContainer />);
|
||||
const box = document.querySelector('.page-transition-enter');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('路由处理测试', () => {
|
||||
it('currentPage 变化时应更新', () => {
|
||||
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
rerender(
|
||||
<SnackbarProvider>
|
||||
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
||||
</SnackbarProvider>,
|
||||
);
|
||||
|
||||
const box = document.querySelector('.page-transition-enter');
|
||||
expect(box).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('页面级错误隔离', () => {
|
||||
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
// 验证 RouterContainer 的 Box 结构存在
|
||||
const routerBox = container.querySelector('.page-transition-dashboard');
|
||||
expect(routerBox).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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';
|
||||
|
||||
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.getByText('storageCleaner:confirmTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应显示警告信息', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText(/storageCleaner:irreversible/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应将选中的选项显示为标签', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
|
||||
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
|
||||
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应显示取消和确认按钮', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByRole('button', { name: /common:buttons.cancel/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /storageCleaner:confirmAction/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击取消时应调用 onClose', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common:buttons.cancel/i }));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('点击确认时应调用 onConfirm', () => {
|
||||
renderComponent();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /storageCleaner:confirmAction/i }));
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('选项过滤测试', () => {
|
||||
it('应仅显示选中的选项', () => {
|
||||
const partialOptions: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: false,
|
||||
indexedDB: true,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
renderComponent({ options: partialOptions });
|
||||
|
||||
expect(screen.getByText('storageCleaner:options.localStorage')).toBeInTheDocument();
|
||||
expect(screen.getByText('storageCleaner:options.indexedDB')).toBeInTheDocument();
|
||||
expect(screen.queryByText('storageCleaner:options.sessionStorage')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('storageCleaner:options.cookies')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应处理空选项', () => {
|
||||
const emptyOptions: StorageCleanerOptions = {
|
||||
localStorage: false,
|
||||
sessionStorage: false,
|
||||
indexedDB: false,
|
||||
cookies: false,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
renderComponent({ options: emptyOptions });
|
||||
|
||||
const chips = screen.queryAllByRole('button');
|
||||
expect(chips.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('对话框行为测试', () => {
|
||||
it('open 为 false 时不应渲染', () => {
|
||||
renderComponent({ open: false });
|
||||
expect(screen.queryByText('storageCleaner:confirmTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应使用不同选项渲染', () => {
|
||||
const customOptions: StorageCleanerOptions = {
|
||||
localStorage: false,
|
||||
sessionStorage: true,
|
||||
indexedDB: false,
|
||||
cookies: true,
|
||||
cacheStorage: false,
|
||||
serviceWorkers: false,
|
||||
};
|
||||
|
||||
renderComponent({ options: customOptions });
|
||||
|
||||
expect(screen.getByText('storageCleaner:options.sessionStorage')).toBeInTheDocument();
|
||||
expect(screen.getByText('storageCleaner:options.cookies')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
|
||||
describe('SwitchButtonGroup 组件', () => {
|
||||
const options = [
|
||||
{ value: 'a', label: '选项A' },
|
||||
{ value: 'b', label: '选项B' },
|
||||
];
|
||||
|
||||
it('应渲染所有选项按钮', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /选项A/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /选项B/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应高亮当前选中的按钮', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const buttonA = screen.getByRole('button', { name: /选项A/i });
|
||||
const buttonB = screen.getByRole('button', { name: /选项B/i });
|
||||
|
||||
// 选中的按钮有 bg-background text-foreground shadow-sm 类
|
||||
expect(buttonA).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||
// 未选中的按钮有 hover:bg-background/50 类
|
||||
expect(buttonB).toHaveClass('hover:bg-background/50');
|
||||
});
|
||||
|
||||
it('点击未选中按钮时应触发 onChange 并传入选中值', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选项B/i }));
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
expect(handleChange).toHaveBeenCalledWith('b');
|
||||
});
|
||||
|
||||
it('点击已选中按钮时不应触发 onChange', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选项A/i }));
|
||||
// 新组件每次点击都会触发 onChange
|
||||
expect(handleChange).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('应支持通过 className 自定义样式', () => {
|
||||
const { container } = render(
|
||||
<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} className="custom-group" />,
|
||||
);
|
||||
|
||||
const group = container.firstChild;
|
||||
expect(group).toHaveClass('custom-group');
|
||||
});
|
||||
|
||||
it('应支持 size 属性', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} size="small" />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toHaveClass('text-xs');
|
||||
});
|
||||
|
||||
it('应支持 buttonSx 自定义按钮样式', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应支持 ReactNode 类型的 label', () => {
|
||||
const nodeOptions = [{ value: 'x', label: <span data-testid="custom-label">自定义</span> }];
|
||||
render(<SwitchButtonGroup value="x" options={nodeOptions} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('custom-label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('默认按钮样式应禁止文字换行', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toHaveClass('whitespace-nowrap');
|
||||
});
|
||||
|
||||
it('buttonSx 传入时应覆盖默认换行样式', () => {
|
||||
render(<SwitchButtonGroup value="a" options={options} onChange={vi.fn()} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /选项A/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('number 类型支持', () => {
|
||||
const numberOptions = [
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 4, label: '4' },
|
||||
];
|
||||
|
||||
it('应支持 number 类型的 value 渲染', () => {
|
||||
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /^2$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^4$/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应高亮 number 类型的当前选中项', () => {
|
||||
render(<SwitchButtonGroup value={4} options={numberOptions} onChange={vi.fn()} />);
|
||||
|
||||
const button2 = screen.getByRole('button', { name: /^2$/i });
|
||||
const button4 = screen.getByRole('button', { name: /^4$/i });
|
||||
|
||||
expect(button2).toHaveClass('hover:bg-background/50');
|
||||
expect(button4).toHaveClass('bg-background', 'text-foreground', 'shadow-sm');
|
||||
});
|
||||
|
||||
it('点击 number 选项时应传回 number 值', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<SwitchButtonGroup value={2} options={numberOptions} onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^4$/i }));
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
expect(handleChange).toHaveBeenCalledWith(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
|
||||
describe('TextInputArea 组件', () => {
|
||||
describe('基础渲染', () => {
|
||||
it('应渲染 placeholder', () => {
|
||||
render(<TextInputArea placeholder="请输入文本..." />);
|
||||
expect(screen.getByPlaceholderText('请输入文本...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染传入的 value', () => {
|
||||
render(<TextInputArea value="测试内容" onChange={() => {}} />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveValue('测试内容');
|
||||
});
|
||||
|
||||
it('默认显示清空按钮', () => {
|
||||
render(<TextInputArea value="有内容" onChange={() => {}} />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.clear' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无内容时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(screen.queryByRole('button', { name: 'textInputArea.clear' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disabled 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('readOnly 时清空按钮应隐藏', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('showClear=false 时不显示清空按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} showClear={false} />);
|
||||
expect(screen.queryByTitle('清空')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('受控模式', () => {
|
||||
it('输入时触发 onChange', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮触发 onChange("")', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={handleChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('非受控模式', () => {
|
||||
it('defaultValue 应显示初始值', () => {
|
||||
render(<TextInputArea defaultValue="初始值" />);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('初始值');
|
||||
});
|
||||
|
||||
it('输入后应更新内部值', () => {
|
||||
render(<TextInputArea defaultValue="" />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '新内容' } });
|
||||
|
||||
expect(textarea).toHaveValue('新内容');
|
||||
});
|
||||
|
||||
it('清空按钮应清空内容', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowCopy 复制功能', () => {
|
||||
it('allowCopy 且有内容时显示复制按钮', () => {
|
||||
render(<TextInputArea value="可复制的内容" onChange={() => {}} allowCopy />);
|
||||
expect(screen.getByRole('button', { name: 'textInputArea.copyContent' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy 但无内容时隐藏复制按钮', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} allowCopy />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allowCopy=false 时不显示复制按钮', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} />);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'textInputArea.copyContent' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('复制时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
|
||||
it('复制失败时调用 clipboard writeText 并捕获错误', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi
|
||||
.spyOn(navigator.clipboard, 'writeText')
|
||||
.mockRejectedValue(new Error('失败'));
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showCount 字符计数', () => {
|
||||
it('显示当前字符数', () => {
|
||||
render(<TextInputArea value="hello" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('空内容时显示 0', () => {
|
||||
render(<TextInputArea value="" onChange={() => {}} showCount />);
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('设置 maxLength 时显示计数上限', () => {
|
||||
render(<TextInputArea value="ab" onChange={() => {}} showCount maxLength={10} />);
|
||||
expect(screen.getByText('2 / 10')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLength', () => {
|
||||
it('超出 maxLength 的输入应被截断', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123456' } });
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('未超出 maxLength 的输入应正常触发', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(<TextInputArea value="" onChange={handleChange} maxLength={5} />);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: '123' } });
|
||||
|
||||
expect(handleChange).toHaveBeenCalledWith('123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('验证规则', () => {
|
||||
it('onChange 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'ab' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onBlur 触发时验证失败应设置 error', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onBlur"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.blur(textarea);
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('验证通过不应显示错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'abcd' } });
|
||||
|
||||
expect(screen.queryByText('至少3个字符')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('onAction 触发时验证失败应阻止 action 执行', () => {
|
||||
const handleAction = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onAction"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
actions={[
|
||||
{
|
||||
key: 'test',
|
||||
label: '执行',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('执行'));
|
||||
|
||||
expect(handleAction).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('操作栏 actions', () => {
|
||||
it('应渲染顶部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'top-action', label: '顶部操作', onClick: vi.fn() }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('顶部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染底部操作按钮', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'bottom-action', label: '底部操作', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('底部操作')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击操作按钮触发 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: handleClick }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('操作'));
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(
|
||||
'内容',
|
||||
expect.objectContaining({
|
||||
clear: expect.any(Function),
|
||||
setError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('disabled 为 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: true }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disabled 为函数且返回 true 时按钮应禁用', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
actions={[{ key: 'act', label: '操作', onClick: vi.fn(), disabled: (v) => !v }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('操作')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('primary 类型按钮应使用 contained 样式', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="内容"
|
||||
onChange={() => {}}
|
||||
actions={[
|
||||
{ key: 'p', label: '主要', type: 'primary', position: 'bottom', onClick: vi.fn() },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const btn = screen.getByText('主要');
|
||||
expect(btn).toHaveClass('bg-primary', 'text-primary-foreground');
|
||||
});
|
||||
});
|
||||
|
||||
describe('title', () => {
|
||||
it('应渲染 title', () => {
|
||||
render(<TextInputArea title="输入区域" value="" onChange={() => {}} />);
|
||||
expect(screen.getByText('输入区域')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('不设置 title 时不渲染标题', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} />);
|
||||
expect(container.querySelector('.text-muted-foreground')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled 和 readOnly', () => {
|
||||
it('disabled 时输入框应禁用', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} disabled />);
|
||||
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('readOnly 时输入框应只读', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} readOnly />);
|
||||
// MUI TextField 的 readOnly 通过 inputProps 设置,textarea 不会被禁用
|
||||
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFocus', () => {
|
||||
it('autoFocus 应自动聚焦', () => {
|
||||
render(<TextInputArea autoFocus value="" onChange={() => {}} />);
|
||||
expect(document.activeElement).toBe(screen.getByRole('textbox'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('复制功能', () => {
|
||||
it('复制成功时调用 clipboard writeText', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeTextSpy = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined);
|
||||
|
||||
render(<TextInputArea value="测试" onChange={() => {}} allowCopy />);
|
||||
await user.click(screen.getByRole('button', { name: 'textInputArea.copyContent' }));
|
||||
|
||||
expect(writeTextSpy).toHaveBeenCalledWith('测试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClear 回调', () => {
|
||||
it('点击清空按钮时应调用 onClear', () => {
|
||||
const handleClear = vi.fn();
|
||||
render(<TextInputArea value="内容" onChange={() => {}} onClear={handleClear} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(handleClear).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('不传 onClear 时清空按钮应正常工作', () => {
|
||||
render(<TextInputArea defaultValue="内容" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'textInputArea.clear' }));
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('externalError 外部错误', () => {
|
||||
it('设置 externalError 时应显示错误状态', () => {
|
||||
render(<TextInputArea value="内容" onChange={() => {}} externalError="JSON 格式无效" />);
|
||||
|
||||
expect(screen.getByText('JSON 格式无效')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 为空时应隐藏错误状态', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea value="内容" onChange={() => {}} externalError="错误" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('错误')).toBeInTheDocument();
|
||||
|
||||
rerender(<TextInputArea value="内容" onChange={() => {}} externalError="" />);
|
||||
|
||||
expect(screen.queryByText('错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 优先级高于内部验证错误', () => {
|
||||
render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '内部验证错误' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
expect(screen.queryByText('内部验证错误')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('externalError 清除后应显示内部验证错误', () => {
|
||||
const { rerender } = render(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
externalError="外部错误"
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('外部错误')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TextInputArea
|
||||
value="ab"
|
||||
onChange={() => {}}
|
||||
validateTrigger="onChange"
|
||||
rules={[{ validator: (v) => v.length >= 3, message: '至少3个字符' }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('外部错误')).not.toBeInTheDocument();
|
||||
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: 'a' } });
|
||||
|
||||
expect(screen.getByText('至少3个字符')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoResize', () => {
|
||||
it('autoResize=true 时设置 minRows/maxRows', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} minRows={3} maxRows={8} />,
|
||||
);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('autoResize=false 时设置固定 rows', () => {
|
||||
const { container } = render(<TextInputArea value="" onChange={() => {}} minRows={5} />);
|
||||
|
||||
const textarea = container.querySelector('textarea');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式集成', () => {
|
||||
it('应透传 className', () => {
|
||||
const { container } = render(
|
||||
<TextInputArea value="" onChange={() => {}} className="custom-class" />,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ToolCard from '@/pages/Dashboard/ToolCard';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
describe('ToolCard 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('应渲染标题和描述', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="测试工具"
|
||||
description="这是一个测试工具"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onNavigate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
||||
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无描述时仅渲染标题', () => {
|
||||
render(<ToolCard title="仅标题" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||
|
||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染图标', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="带图标" colorKey="primary" icon={Clock} onNavigate={() => {}} />,
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('提供快照内容时应渲染快照', () => {
|
||||
render(
|
||||
<ToolCard
|
||||
title="带快照"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onNavigate={() => {}}
|
||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('未提供快照时不渲染快照区域', () => {
|
||||
const { container } = render(
|
||||
<ToolCard
|
||||
title="无快照"
|
||||
colorKey="primary"
|
||||
icon={Clock}
|
||||
onClick={() => {}}
|
||||
onNavigate={function (): void {
|
||||
throw new Error('Function not implemented.');
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||
render(<ToolCard title="可聚焦" colorKey="primary" icon={Clock} onNavigate={() => {}} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击时应调用 onClick', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<ToolCard title="可点击" colorKey="primary" icon={Clock} onNavigate={handleClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /可点击/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('按 Enter 键时应调用 onClick', async () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<ToolCard title="键盘可触发" colorKey="primary" icon={Clock} onNavigate={handleClick} />,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /键盘可触发/ });
|
||||
await act(async () => {
|
||||
button.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
});
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('样式测试', () => {
|
||||
it('应应用自定义颜色代码', () => {
|
||||
const { container } = render(
|
||||
<ToolCard title="自定义颜色" colorKey="warning" icon={Clock} onNavigate={() => {}} />,
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import React from 'react';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import { RouterProvider } from '@/providers/RouterProvider';
|
||||
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
||||
|
||||
// matchMedia must be mocked before ThemeModeProvider is imported
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goBack: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
useRouter: () => mockRouterValue,
|
||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
describe('TopBar 组件', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithProvider = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<ThemeModeProvider>
|
||||
<RouterProvider>{ui}</RouterProvider>
|
||||
</ThemeModeProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByLabelText('common:buttons.back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.queryByLabelText('common:buttons.back')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('应渲染设置按钮', () => {
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
expect(screen.getByLabelText('common:buttons.settings')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('交互测试', () => {
|
||||
it('点击设置按钮时应调用 onOpenOptions', () => {
|
||||
const handleOpenOptions = vi.fn();
|
||||
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('common:buttons.settings'));
|
||||
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('点击返回按钮时应调用 goBack', () => {
|
||||
mockRouterValue.currentPage = 'timestamp';
|
||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('common:buttons.back'));
|
||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,101 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = 'DialogFooter';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,150 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
FEATURES,
|
||||
getAllFeatureKeys,
|
||||
getDefaultPageOrder,
|
||||
getDefaultVisibleFeatureKeys,
|
||||
getFeatureByKey,
|
||||
} from '@/config/features';
|
||||
|
||||
describe('features', () => {
|
||||
describe('FEATURES', () => {
|
||||
it('should have 10 features defined', () => {
|
||||
expect(FEATURES).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should have all required properties for each feature', () => {
|
||||
FEATURES.forEach((feature) => {
|
||||
expect(feature).toHaveProperty('key');
|
||||
expect(feature).toHaveProperty('labelKey');
|
||||
expect(feature).toHaveProperty('descriptionKey');
|
||||
expect(feature).toHaveProperty('defaultVisible');
|
||||
expect(feature).toHaveProperty('components');
|
||||
expect(typeof feature.key).toBe('string');
|
||||
expect(typeof feature.labelKey).toBe('string');
|
||||
expect(typeof feature.descriptionKey).toBe('string');
|
||||
expect(typeof feature.defaultVisible).toBe('boolean');
|
||||
expect(typeof feature.components).toBe('object');
|
||||
expect(feature.components).toHaveProperty('popup');
|
||||
expect(feature.components).toHaveProperty('sidepanel');
|
||||
expect(feature.components).toHaveProperty('tab');
|
||||
|
||||
// Optional UI properties for non-hidden features
|
||||
if (feature.key !== 'dashboard') {
|
||||
expect(feature).toHaveProperty('icon');
|
||||
expect(feature).toHaveProperty('themeColorKey');
|
||||
expect(typeof feature.themeColorKey).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should have unique keys for each feature', () => {
|
||||
const keys = FEATURES.map((f) => f.key);
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatureByKey', () => {
|
||||
it('should return dashboard feature', () => {
|
||||
const feature = getFeatureByKey('dashboard');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('dashboard');
|
||||
expect(feature?.labelKey).toBe('features:dashboard.title');
|
||||
});
|
||||
|
||||
it('should return timestamp feature', () => {
|
||||
const feature = getFeatureByKey('timestamp');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('timestamp');
|
||||
expect(feature?.labelKey).toBe('features:timestamp.title');
|
||||
expect(feature?.themeColorKey).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return storageCleaner feature', () => {
|
||||
const feature = getFeatureByKey('storageCleaner');
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.key).toBe('storageCleaner');
|
||||
expect(feature?.labelKey).toBe('features:storageCleaner.title');
|
||||
});
|
||||
|
||||
it('should return undefined for invalid key', () => {
|
||||
const feature = getFeatureByKey('invalid' as any);
|
||||
expect(feature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDefaultVisibleFeatureKeys', () => {
|
||||
it('should return only visible features', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
visibleKeys.forEach((key) => {
|
||||
const feature = getFeatureByKey(key);
|
||||
expect(feature?.defaultVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include dashboard, timestamp, storageCleaner, qrCode', () => {
|
||||
const visibleKeys = getDefaultVisibleFeatureKeys();
|
||||
expect(visibleKeys).toContain('dashboard');
|
||||
expect(visibleKeys).toContain('timestamp');
|
||||
expect(visibleKeys).toContain('storageCleaner');
|
||||
expect(visibleKeys).toContain('qrCode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllFeatureKeys', () => {
|
||||
it('should return all feature keys', () => {
|
||||
const allKeys = getAllFeatureKeys();
|
||||
expect(allKeys).toHaveLength(10);
|
||||
expect(allKeys).toContain('dashboard');
|
||||
expect(allKeys).toContain('timestamp');
|
||||
expect(allKeys).toContain('storageCleaner');
|
||||
expect(allKeys).toContain('qrCode');
|
||||
expect(allKeys).toContain('textStatistics');
|
||||
expect(allKeys).toContain('jwt');
|
||||
expect(allKeys).toContain('jsonDiff');
|
||||
expect(allKeys).toContain('base64Converter');
|
||||
expect(allKeys).toContain('markdownToHtml');
|
||||
expect(allKeys).toContain('htmlToMarkdown');
|
||||
});
|
||||
});
|
||||
|
||||
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 9 items in page order', () => {
|
||||
const pageOrder = getDefaultPageOrder();
|
||||
expect(pageOrder).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { type ComponentType, lazy } from 'react';
|
||||
import type { LucideProps } from 'lucide-react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import {
|
||||
Clock,
|
||||
Database,
|
||||
QrCode,
|
||||
FileText,
|
||||
Key,
|
||||
GitCompareArrows,
|
||||
ArrowLeftRight,
|
||||
Code,
|
||||
File,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||
|
||||
// 懒加载页面组件
|
||||
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
|
||||
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
|
||||
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
|
||||
const QrCodePage = lazy(() => import('@/pages/QrCode'));
|
||||
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
||||
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
||||
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
|
||||
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
|
||||
const MarkdownToHtmlPage = lazy(() => import('@/pages/MarkdownToHtml'));
|
||||
const HtmlToMarkdownPage = lazy(() => import('@/pages/HtmlToMarkdown'));
|
||||
|
||||
export interface FeatureConfig {
|
||||
key: PageType;
|
||||
labelKey: string;
|
||||
descriptionKey: string;
|
||||
themeColorKey?: PaletteColorKey;
|
||||
icon?: ComponentType<LucideProps>;
|
||||
defaultVisible: boolean;
|
||||
components: {
|
||||
popup: ComponentType;
|
||||
sidepanel: ComponentType;
|
||||
tab: ComponentType;
|
||||
};
|
||||
}
|
||||
|
||||
export const FEATURES: FeatureConfig[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
labelKey: 'features:dashboard.title',
|
||||
descriptionKey: '',
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: DashboardPage,
|
||||
sidepanel: DashboardPage,
|
||||
tab: DashboardPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
labelKey: 'features:timestamp.title',
|
||||
descriptionKey: 'features:timestamp.description',
|
||||
themeColorKey: 'primary',
|
||||
icon: Clock,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: TimestampPage,
|
||||
sidepanel: TimestampPage,
|
||||
tab: TimestampPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'storageCleaner',
|
||||
labelKey: 'features:storageCleaner.title',
|
||||
descriptionKey: 'features:storageCleaner.description',
|
||||
themeColorKey: 'warning',
|
||||
icon: Database,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: StorageCleanerPage,
|
||||
sidepanel: StorageCleanerPage,
|
||||
tab: StorageCleanerPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'qrCode',
|
||||
labelKey: 'features:qrCode.title',
|
||||
descriptionKey: 'features:qrCode.description',
|
||||
themeColorKey: 'success',
|
||||
icon: QrCode,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: QrCodePage,
|
||||
sidepanel: QrCodePage,
|
||||
tab: QrCodePage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'textStatistics',
|
||||
labelKey: 'features:textStatistics.title',
|
||||
descriptionKey: 'features:textStatistics.description',
|
||||
themeColorKey: 'secondary',
|
||||
icon: FileText,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: TextStatisticsPage,
|
||||
sidepanel: TextStatisticsPage,
|
||||
tab: TextStatisticsPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'jwt',
|
||||
labelKey: 'features:jwt.title',
|
||||
descriptionKey: 'features:jwt.description',
|
||||
themeColorKey: 'info',
|
||||
icon: Key,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: JwtPage,
|
||||
sidepanel: JwtPage,
|
||||
tab: JwtPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'jsonDiff',
|
||||
labelKey: 'features:jsonDiff.title',
|
||||
descriptionKey: 'features:jsonDiff.description',
|
||||
themeColorKey: 'primary',
|
||||
icon: GitCompareArrows,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: JsonToolsPage,
|
||||
sidepanel: JsonToolsPage,
|
||||
tab: JsonToolsPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'base64Converter',
|
||||
labelKey: 'features:base64Converter.title',
|
||||
descriptionKey: 'features:base64Converter.description',
|
||||
themeColorKey: 'info',
|
||||
icon: ArrowLeftRight,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: Base64ConverterPage,
|
||||
sidepanel: Base64ConverterPage,
|
||||
tab: Base64ConverterPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'markdownToHtml',
|
||||
labelKey: 'features:markdownToHtml.title',
|
||||
descriptionKey: 'features:markdownToHtml.description',
|
||||
themeColorKey: 'secondary',
|
||||
icon: Code,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: MarkdownToHtmlPage,
|
||||
sidepanel: MarkdownToHtmlPage,
|
||||
tab: MarkdownToHtmlPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'htmlToMarkdown',
|
||||
labelKey: 'features:htmlToMarkdown.title',
|
||||
descriptionKey: 'features:htmlToMarkdown.description',
|
||||
themeColorKey: 'secondary',
|
||||
icon: File,
|
||||
defaultVisible: true,
|
||||
components: {
|
||||
popup: HtmlToMarkdownPage,
|
||||
sidepanel: HtmlToMarkdownPage,
|
||||
tab: HtmlToMarkdownPage,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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,56 @@
|
||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||
|
||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||
|
||||
export type UnitType = 'ms' | 's';
|
||||
export type ZoneType = (typeof ZONES)[number];
|
||||
|
||||
export const THEME_COLORS = {
|
||||
primary: '#1976d2',
|
||||
primaryDark: '#1565c0',
|
||||
primaryLight: '#42a5f5',
|
||||
success: '#2e7d32',
|
||||
successDark: '#1b5e20',
|
||||
successLight: '#4caf50',
|
||||
warning: '#e65100',
|
||||
warningDark: '#bf360c',
|
||||
warningLight: '#ff9800',
|
||||
error: '#c62828',
|
||||
errorDark: '#b71c1c',
|
||||
errorLight: '#f44336',
|
||||
purple: '#6a1b9a',
|
||||
purpleDark: '#4a148c',
|
||||
purpleLight: '#9c27b0',
|
||||
indigo: '#303f9f',
|
||||
indigoDark: '#1a237e',
|
||||
indigoLight: '#7986cb',
|
||||
white: '#FFFFFF',
|
||||
black: '#000000',
|
||||
} as const;
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
success: THEME_COLORS.success,
|
||||
warning: THEME_COLORS.warning,
|
||||
error: THEME_COLORS.error,
|
||||
info: THEME_COLORS.primary,
|
||||
} as const;
|
||||
|
||||
export const timestampPageStyles = {
|
||||
primaryColor: THEME_COLORS.primary,
|
||||
};
|
||||
|
||||
export const storageCleanerPageStyles = {
|
||||
warningColor: THEME_COLORS.warning,
|
||||
};
|
||||
|
||||
export const qrCodePageStyles = {
|
||||
primaryColor: THEME_COLORS.success,
|
||||
};
|
||||
|
||||
export const textStatisticsPageStyles = {
|
||||
primaryColor: THEME_COLORS.purple,
|
||||
};
|
||||
|
||||
export const base64ConverterPageStyles = {
|
||||
primaryColor: THEME_COLORS.indigo,
|
||||
};
|
||||
@@ -1,880 +0,0 @@
|
||||
# Storage Cleaner Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a storage cleaner feature to the browser extension popup that allows users to clear localStorage, sessionStorage, IndexedDB, Cookies, Cache Storage, andress Workers for the current page.
|
||||
|
||||
**Architecture:** Add new StorageCleanerPage component with tab switching in the popup, using chrome.cookies API for cookies and script injection for other storage types. User preferences are persisted using Chrome Storage.
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript, Material UI, Chrome Extension APIs
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
entrypoints/popup/
|
||||
├── App.tsx (modify: add tab switching)
|
||||
└── pages/
|
||||
├── TimestampPage.tsx (no change)
|
||||
└── StorageCleanerPage.tsx (create: new storage cleaner page)
|
||||
|
||||
types/
|
||||
└── storage.d.ts (modify: add storage cleaner types)
|
||||
|
||||
utils/
|
||||
└── storageCleaner.ts (create: storage cleaning utilities)
|
||||
|
||||
wxt.config.ts (modify: add cookies permission)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add TypeScript Types for Storage Cleaner
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `types/storage.d.ts`
|
||||
|
||||
- [ ] **Step 1: Add storage cleaner types to StorageSchema and interfaces**
|
||||
|
||||
```typescript
|
||||
export interface StorageSchema {
|
||||
'app/lastRoute': string;
|
||||
'app/theme': string;
|
||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
||||
}
|
||||
|
||||
export interface StorageCleanerPreferences {
|
||||
autoRefresh: boolean;
|
||||
selectedTypes: StorageCleanerOptions;
|
||||
}
|
||||
|
||||
export interface StorageCleanerOptions {
|
||||
localStorage: boolean;
|
||||
sessionStorage: boolean;
|
||||
indexedDB: boolean;
|
||||
cookies: boolean;
|
||||
cacheStorage: boolean;
|
||||
serviceWorkers: boolean;
|
||||
}
|
||||
|
||||
export type StorageCleanResult =
|
||||
| {
|
||||
success: true;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface CleaningResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
localStorage?: StorageCleanResult;
|
||||
sessionStorage?: StorageCleanResult;
|
||||
indexedDB?: StorageCleanResult;
|
||||
cookies?: StorageCleanResult;
|
||||
cacheStorage?: StorageCleanResult;
|
||||
serviceWorkers?: StorageCleanResult;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit TypeScript types**
|
||||
|
||||
```bash
|
||||
git add types/storage.d.ts
|
||||
git commit -m "feat: add TypeScript types for storage cleaner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add Cookies Permission to Manifest
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `wxt.config.ts:10-18`
|
||||
|
||||
- [ ] **Step 1: Add 'cookies' permission to manifest**
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage',
|
||||
'unlimitedStorage',
|
||||
'clipboardWrite',
|
||||
'activeTab',
|
||||
'scripting',
|
||||
'tabs',
|
||||
'debugger',
|
||||
'cookies', // Add this line
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test build to ensure manifest is valid**
|
||||
|
||||
Run: `npm run compile`
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 3: Commit manifest changes**
|
||||
|
||||
```bash
|
||||
git add wxt.config.ts
|
||||
git commit -m "feat: add cookies permission to manifest"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Storage Cleaning Utilities
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `utils/storageCleaner.ts`
|
||||
|
||||
- [ ] **Step 1: Create storage cleaning utility file with helper functions**
|
||||
|
||||
```typescript
|
||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanResult } from 'types/storage';
|
||||
|
||||
const RESTRICTED_PROTOCOLS = [
|
||||
'chrome:',
|
||||
'chrome-extension:',
|
||||
'about:',
|
||||
'edge:',
|
||||
'view-source:',
|
||||
'file:',
|
||||
'data:',
|
||||
] as const;
|
||||
|
||||
export async function getCurrentTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab;
|
||||
}
|
||||
|
||||
export function isRestrictedUrl(url?: string): boolean {
|
||||
if (!url) return true;
|
||||
return RESTRICTED_PROTOCOLS.some((p) => url.startsWith(p));
|
||||
}
|
||||
|
||||
export async function clearCookies(url: string): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const cookies = await chrome.cookies.getAll({ url });
|
||||
for (const cookie of cookies) {
|
||||
await chrome.cookies.remove({
|
||||
url,
|
||||
name: cookie.name,
|
||||
storeId: cookie.storeId,
|
||||
});
|
||||
}
|
||||
return { success: true, count: cookies.length };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearLocalStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const count = localStorage.length;
|
||||
localStorage.clear();
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
if (result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearSessionStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
const count = sessionStorage.length;
|
||||
sessionStorage.clear();
|
||||
return { count };
|
||||
},
|
||||
});
|
||||
if (result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearIndexedDB(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const result = await chrome.scripting.executeScript<{ count: number } | { error: string }>({
|
||||
target: { tabId },
|
||||
func: () => {
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
return indexedDB.databases().then(async (databases) => {
|
||||
let count = 0;
|
||||
for (const db of databases) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', db.name);
|
||||
};
|
||||
deleteReq.onsuccess = () => resolve();
|
||||
deleteReq.onerror = () => reject();
|
||||
});
|
||||
count++;
|
||||
}
|
||||
return { count };
|
||||
});
|
||||
}
|
||||
return { error: 'databases_api_unavailable' };
|
||||
},
|
||||
});
|
||||
if (result.result) {
|
||||
if ('error' in result.result) {
|
||||
return { success: false, error: result.result.error };
|
||||
}
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectClearCacheStorage(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
for (const name of cacheNames) {
|
||||
await caches.delete(name);
|
||||
}
|
||||
return { count: cacheNames.length };
|
||||
}
|
||||
return { count: 0 };
|
||||
},
|
||||
});
|
||||
if (result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectUnregisterServiceWorkers(tabId: number): Promise<StorageCleanResult> {
|
||||
try {
|
||||
const result = await chrome.scripting.executeScript<{ count: number }>({
|
||||
target: { tabId },
|
||||
func: async () => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
for (const registration of registrations) {
|
||||
await registration.unregister();
|
||||
}
|
||||
return { count: registrations.length };
|
||||
}
|
||||
return { count: 0 };
|
||||
},
|
||||
});
|
||||
if (result.result) {
|
||||
return { success: true, count: result.result.count };
|
||||
}
|
||||
return { success: false, error: 'No result returned' };
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStorage(
|
||||
tabId: number,
|
||||
url: string,
|
||||
options: StorageCleanerOptions,
|
||||
): Promise<CleaningResult> {
|
||||
const result: CleaningResult = { success: true };
|
||||
|
||||
if (options.localStorage) {
|
||||
result.localStorage = await injectClearLocalStorage(tabId);
|
||||
}
|
||||
|
||||
if (options.sessionStorage) {
|
||||
result.sessionStorage = await injectClearSessionStorage(tabId);
|
||||
}
|
||||
|
||||
if (options.indexedDB) {
|
||||
result.indexedDB = await injectClearIndexedDB(tabId);
|
||||
}
|
||||
|
||||
if (options.cookies) {
|
||||
result.cookies = await clearCookies(url);
|
||||
}
|
||||
|
||||
if (options.cacheStorage) {
|
||||
result.cacheStorage = await injectClearCacheStorage(tabId);
|
||||
}
|
||||
|
||||
if (options.serviceWorkers) {
|
||||
result.serviceWorkers = await injectUnregisterServiceWorkers(tabId);
|
||||
}
|
||||
|
||||
// Check if any operation failed
|
||||
const failures = Object.values(result).filter(
|
||||
(r): r is StorageCleanResult => r?.success === false,
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
result.success = false;
|
||||
result.error = '部分清理失败';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatCleaningResult(result: CleaningResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (result.localStorage?.success) {
|
||||
parts.push(`${result.localStorage.count} 个 localStorage`);
|
||||
}
|
||||
if (result.sessionStorage?.success) {
|
||||
parts.push(`${result.sessionStorage.count} 个 sessionStorage`);
|
||||
}
|
||||
if (result.indexedDB?.success) {
|
||||
parts.push(`${result.indexedDB.count} 个 IndexedDB`);
|
||||
}
|
||||
if (result.cookies?.success) {
|
||||
parts.push(`${result.cookies.count} 个 Cookies`);
|
||||
}
|
||||
if (result.cacheStorage?.success) {
|
||||
parts.push(`${result.cacheStorage.count} 个 Cache`);
|
||||
}
|
||||
if (result.serviceWorkers?.success) {
|
||||
parts.push(`${result.serviceWorkers.count} 个 Service Workers`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return '该页面没有可清理的存储数据';
|
||||
}
|
||||
|
||||
return `清理了 ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
export function isEmptyResult(result: CleaningResult): boolean {
|
||||
const values = Object.values(result).filter(
|
||||
(r): r is StorageCleanResult => r?.success === true && r.count > 0,
|
||||
);
|
||||
return values.length === 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit storage cleaning utilities**
|
||||
|
||||
```bash
|
||||
git add utils/storageCleaner.ts
|
||||
git commit -m "feat: add storage cleaning utility functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create StorageCleanerPage Component
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Create StorageCleanerPage component with UI and logic**
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Box,
|
||||
Checkbox,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
||||
import {
|
||||
getCurrentTab,
|
||||
isRestrictedUrl,
|
||||
clearStorage,
|
||||
formatCleaningResult,
|
||||
isEmptyResult,
|
||||
} from '@/utils/storageCleaner';
|
||||
|
||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
};
|
||||
|
||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||
autoRefresh: true,
|
||||
selectedTypes: DEFAULT_OPTIONS,
|
||||
};
|
||||
|
||||
export default function StorageCleanerPage() {
|
||||
const [domain, setDomain] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
||||
open: false,
|
||||
message: '',
|
||||
});
|
||||
|
||||
// Load tab info and user preferences
|
||||
useEffect(() => {
|
||||
const loadInfo = async () => {
|
||||
const tab = await getCurrentTab();
|
||||
|
||||
if (!tab || !tab.url) {
|
||||
setError('无法获取当前标签页');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
setError('存储清理功能不支持此页面');
|
||||
return;
|
||||
}
|
||||
|
||||
setDomain(new URL(tab.url).hostname);
|
||||
|
||||
// Load user preferences
|
||||
const prefs = await storageUtil.get(
|
||||
'storageCleaner/preferences',
|
||||
DEFAULT_PREFERENCES,
|
||||
);
|
||||
setAutoRefresh(prefs.autoRefresh);
|
||||
setOptions(prefs.selectedTypes);
|
||||
};
|
||||
|
||||
loadInfo();
|
||||
}, []);
|
||||
|
||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const handleClean = useCallback(async () => {
|
||||
const tab = await getCurrentTab();
|
||||
|
||||
if (!tab || !tab.id || !tab.url) {
|
||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||
setResult(cleaningResult);
|
||||
|
||||
// Save user preferences
|
||||
await storageUtil.set('storageCleaner/preferences', {
|
||||
autoRefresh,
|
||||
selectedTypes: options,
|
||||
});
|
||||
|
||||
// Auto refresh if enabled
|
||||
if (autoRefresh && cleaningResult.success) {
|
||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
||||
setTimeout(() => {
|
||||
chrome.tabs.reload(tab.id);
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowConfirm(false);
|
||||
}
|
||||
}, [options, autoRefresh]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
const tab = await getCurrentTab();
|
||||
if (tab?.id) {
|
||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
||||
setTimeout(() => {
|
||||
chrome.tabs.reload(tab.id);
|
||||
}, 1500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
||||
<Alert severity="error" icon={<WarningIcon />}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
||||
存储清理
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
当前页面: {domain || '加载中...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Storage Type Options */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
选择要清理的存储类型:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.localStorage}
|
||||
onChange={() => handleOptionChange('localStorage')}
|
||||
/>
|
||||
}
|
||||
label="localStorage"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.sessionStorage}
|
||||
onChange={() => handleOptionChange('sessionStorage')}
|
||||
/>
|
||||
}
|
||||
label="sessionStorage"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.indexedDB}
|
||||
onChange={() => handleOptionChange('indexedDB')}
|
||||
/>
|
||||
}
|
||||
label="IndexedDB"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.cookies}
|
||||
onChange={() => handleOptionChange('cookies')}
|
||||
/>
|
||||
}
|
||||
label="Cookies"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.cacheStorage}
|
||||
onChange={() => handleOptionChange('cacheStorage')}
|
||||
/>
|
||||
}
|
||||
label="Cache Storage"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.serviceWorkers}
|
||||
onChange={() => handleOptionChange('serviceWorkers')}
|
||||
/>
|
||||
}
|
||||
label="Service Workers"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Auto Refresh Option */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="清理完成后自动刷新页面"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? '清理中...' : '清理'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Result Display */}
|
||||
{result && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Alert
|
||||
severity={result.success ? 'success' : 'error'}
|
||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
||||
>
|
||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
||||
</Alert>
|
||||
{!autoRefresh && result.success && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRefresh}
|
||||
fullWidth
|
||||
>
|
||||
刷新页面
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirm && (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.95)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">确认清理</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
||||
将清理以下存储类型:
|
||||
</Typography>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
{options.localStorage && (
|
||||
<Typography variant="body2">- localStorage</Typography>
|
||||
)}
|
||||
{options.sessionStorage && (
|
||||
<Typography variant="body2">- sessionStorage</Typography>
|
||||
)}
|
||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
||||
{options.cacheStorage && (
|
||||
<Typography variant="body2">- Cache Storage</Typography>
|
||||
)}
|
||||
{options.serviceWorkers && (
|
||||
<Typography variant="body2">- Service Workers</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mb: 1 }}
|
||||
>
|
||||
此操作不可撤销。
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="contained" color="error" onClick={handleClean}>
|
||||
确认清理
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Snackbar */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
>
|
||||
<Alert severity="info" variant="filled">
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit StorageCleanerPage component**
|
||||
|
||||
```bash
|
||||
git add entrypoints/popup/pages/StorageCleanerPage.tsx
|
||||
git commit -m "feat: add StorageCleanerPage component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update App.tsx with Tab Switching
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `entrypoints/popup/App.tsx`
|
||||
|
||||
- [ ] **Step 1: Add tab switching logic to App.tsx**
|
||||
|
||||
```typescript
|
||||
import { useState } from 'react';
|
||||
import { Box, Button } from '@mui/material';
|
||||
import TimestampPage from './pages/TimestampPage';
|
||||
import StorageCleanerPage from './pages/StorageCleanerPage';
|
||||
import './App.css';
|
||||
|
||||
type PageType = 'timestamp' | 'storageCleaner';
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<Button
|
||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
||||
onClick={() => setCurrentPage('timestamp')}
|
||||
>
|
||||
时间戳
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
||||
onClick={() => setCurrentPage('storageCleaner')}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
存储清理
|
||||
</Button>
|
||||
</Box>
|
||||
{currentPage === 'timestamp' && <TimestampPage />}
|
||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run type check**
|
||||
|
||||
Run: `npm run compile`
|
||||
Expected: No TypeScript errors
|
||||
|
||||
- [ ] **Step 3: Commit App.tsx changes**
|
||||
|
||||
```bash
|
||||
git add entrypoints/popup/App.tsx
|
||||
git commit -m "feat: add tab switching to App component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Build and Test
|
||||
|
||||
**Files:**
|
||||
|
||||
- No file changes
|
||||
|
||||
- [ ] **Step 1: Build the extension**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: Build succeeds with no errors
|
||||
|
||||
- [ ] **Step 2: Run lint check**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: No linting errors
|
||||
|
||||
- [ ] **Step 3: Load extension in Chrome for manual testing**
|
||||
|
||||
Instructions:
|
||||
|
||||
1. Open Chrome and navigate to `chrome://extensions/`
|
||||
2. Enable Developer Mode
|
||||
3. Click "Load unpacked"
|
||||
4. Select `.output/chrome-mv3` directory
|
||||
5. Test on a regular web page (e.g., example.com)
|
||||
|
||||
- [ ] **Step 4: Commit successful implementation**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "feat: complete storage cleaner feature implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After implementation, verify:
|
||||
|
||||
- [ ] Tab switching works between timestamp and storage cleaner
|
||||
- [ ] Current domain displays correctly
|
||||
- [ ] All storage type checkboxes toggle correctly
|
||||
- [ ] Auto refresh checkbox persists across sessions
|
||||
- [ ] Clear confirmation dialog appears
|
||||
- [ ] Confirmation dialog shows selected storage types
|
||||
- [ ] localStorage clears successfully
|
||||
- [ ] sessionStorage clears successfully
|
||||
- [ ] IndexedDB clears successfully (or shows error if unavailable)
|
||||
- [ ] Cookies clear successfully
|
||||
- [ ] Clear httponly and secure cookies
|
||||
- [ ] Cache Storage clears successfully
|
||||
- [ ] Service Workers unregister successfully
|
||||
- [ ] Result message displays correctly
|
||||
- [ ] Empty state shows friendly message
|
||||
- [ ] Auto refresh works
|
||||
- [ ] Manual refresh button appears when auto-refresh is off
|
||||
- [ ] Restricted pages show error message
|
||||
- [ ] Snackbar notifications appear correctly
|
||||
- [ ] Test on localhost
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues occur during testing:
|
||||
|
||||
1. Revert to before implementation:
|
||||
|
||||
```bash
|
||||
git reset --hard <commit-before-start>
|
||||
```
|
||||
|
||||
2. Or revert specific files:
|
||||
```bash
|
||||
git checkout HEAD -- types/storage.d.ts wxt.config.ts utils/storageCleaner.ts entrypoints/popup/App.tsx entrypoints/popup/pages/StorageCleanerPage.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The popup closes automatically when the page is refreshed - this is expected behavior
|
||||
- IndexedDB.databases() may not be available in all browser versions; the fallback handles this
|
||||
- Chrome Cookies API requires explicit permission, which is added to the manifest
|
||||
- User preferences are persisted using the existing chromeStorage.ts utility
|
||||
@@ -1,387 +0,0 @@
|
||||
# 存储清理功能设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
为浏览器扩展添加一个存储清理功能,允许用户快速清理当前页面的各种存储数据,包括 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers。
|
||||
|
||||
## 目标
|
||||
|
||||
- 提供便捷的页面存储清理功能
|
||||
- 支持多种存储类型清理
|
||||
- 提供清理结果反馈
|
||||
- 支持清理后自动刷新页面
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 组件结构
|
||||
|
||||
```
|
||||
entrypoints/popup/pages/
|
||||
├── TimestampPage.tsx (现有:时间戳转换页面)
|
||||
└── StorageCleanerPage.tsx (新增:存储清理页面)
|
||||
```
|
||||
|
||||
### 页面布局
|
||||
|
||||
在弹窗中添加标签页切换功能,用户可以在时间戳转换和存储清理之间切换。
|
||||
|
||||
**路由实现方案:**
|
||||
|
||||
使用简单的状态管理进行页面切换:
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
type PageType = 'timestamp' | 'storageCleaner';
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<PageType>('timestamp');
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<Button
|
||||
variant={currentPage === 'timestamp' ? 'contained' : 'outlined'}
|
||||
onClick={() => setCurrentPage('timestamp')}
|
||||
>
|
||||
时间戳
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentPage === 'storageCleaner' ? 'contained' : 'outlined'}
|
||||
onClick={() => setCurrentPage('storageCleaner')}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
存储清理
|
||||
</Button>
|
||||
</Box>
|
||||
{currentPage === 'timestamp' && <TimestampPage />}
|
||||
{currentPage === 'storageCleaner' && <StorageCleanerPage />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 用户界面设计
|
||||
|
||||
### 页面组成
|
||||
|
||||
1. **头部区域**
|
||||
- 标题:"存储清理"
|
||||
- 当前域名显示(自动从活动标签页获取)
|
||||
|
||||
2. **存储类型选择区域**
|
||||
- 勾选框:localStorage
|
||||
- 勾选框:sessionStorage
|
||||
- 勾选框:IndexedDB
|
||||
- 勾选框:Cookies
|
||||
- 勾选框:Cache Storage
|
||||
- 勾选框:Service Workers
|
||||
|
||||
3. **自动刷新选项**
|
||||
- 复选框:清理完成后自动刷新页面(默认勾选)
|
||||
|
||||
4. **操作区域**
|
||||
- 清理按钮
|
||||
|
||||
5. **结果显示区域**
|
||||
- 清理成功/失败提示
|
||||
- 清理详情统计(如:"清理了 5 个 localStorage, 3 个 cookies")
|
||||
- 刷新页面按钮(当未勾选自动刷新时显示)
|
||||
|
||||
## 技术实现细节
|
||||
|
||||
### 获取当前标签页域名
|
||||
|
||||
使用 Chrome Tabs API 获取当前活动标签页,并过滤受限页面:
|
||||
|
||||
```typescript
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
|
||||
// 检查受限页面
|
||||
const restrictedProtocols = [
|
||||
'chrome:',
|
||||
'chrome-extension:',
|
||||
'about:',
|
||||
'edge:',
|
||||
'view-source:',
|
||||
'file:',
|
||||
'data:',
|
||||
];
|
||||
|
||||
if (!tab?.url || restrictedProtocols.some((p) => tab.url!.startsWith(p))) {
|
||||
throw new Error('存储清理功能不支持此页面');
|
||||
}
|
||||
|
||||
const domain = new URL(tab.url).hostname;
|
||||
```
|
||||
|
||||
### 清理 Cookies(使用 chrome.cookies API)
|
||||
|
||||
在扩展环境中直接执行,不需要注入页面:
|
||||
|
||||
```typescript
|
||||
const cookies = await chrome.cookies.getAll({ url: tab.url });
|
||||
let count = 0;
|
||||
for (const cookie of cookies) {
|
||||
await chrome.cookies.remove({
|
||||
url: tab.url,
|
||||
name: cookie.name,
|
||||
storeId: cookie.storeId,
|
||||
});
|
||||
count++;
|
||||
}
|
||||
```
|
||||
|
||||
### 注入脚本清理其他存储
|
||||
|
||||
使用 `chrome.scripting.executeScript` 注入清理脚本:
|
||||
|
||||
```typescript
|
||||
const result = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: () => {
|
||||
// 清理逻辑在页面上下文中执行
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
需要注入到页面执行的存储清理逻辑:
|
||||
|
||||
#### 清理 localStorage
|
||||
|
||||
```javascript
|
||||
const count = localStorage.length;
|
||||
localStorage.clear();
|
||||
return count;
|
||||
```
|
||||
|
||||
#### 清理 sessionStorage
|
||||
|
||||
```javascript
|
||||
const count = sessionStorage.length;
|
||||
sessionStorage.clear();
|
||||
return count;
|
||||
```
|
||||
|
||||
#### 清理 IndexedDB
|
||||
|
||||
```javascript
|
||||
// 检查 indexedDB.databases 方法是否可用
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
const databases = await indexedDB.databases();
|
||||
let count = 0;
|
||||
for (const db of databases) {
|
||||
const deleteReq = indexedDB.deleteDatabase(db.name);
|
||||
deleteReq.onblocked = () => {
|
||||
console.warn('IndexedDB delete blocked:', db.name);
|
||||
};
|
||||
await new Promise((resolve, reject) => {
|
||||
deleteReq.onsuccess = resolve;
|
||||
deleteReq.onerror = reject;
|
||||
});
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
// 降级方案:由于无法获取所有数据库名称,提示返回特殊值表示需要手动操作
|
||||
return { error: 'databases_api_unavailable' };
|
||||
```
|
||||
|
||||
#### 清理 Cache Storage
|
||||
|
||||
```javascript
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
for (const name of cacheNames) {
|
||||
await caches.delete(name);
|
||||
}
|
||||
return cacheNames.length;
|
||||
}
|
||||
return 0;
|
||||
```
|
||||
|
||||
#### 注销 Service Workers
|
||||
|
||||
```javascript
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
let count = 0;
|
||||
for (const registration of registrations) {
|
||||
await registration.unregister();
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
return 0;
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
1. 页面加载时获取当前标签页 URL 并显示域名
|
||||
2. 检查是否为受限页面(chrome://, about:// 等),如果是则显示错误提示
|
||||
3. 用户勾选要清理的存储类型
|
||||
4. 用户选择是否自动刷新页面
|
||||
5. 用户点击清理按钮
|
||||
6. 弹出确认对话框询问用户确认
|
||||
7. 确认后执行清理:
|
||||
- 如果选择 Cookies:直接使用 chrome.cookies API 删除
|
||||
- 其他存储类型:向页面注入清理脚本
|
||||
8. 收集所有清理结果并统计
|
||||
9. 显示清理结果
|
||||
10. 如果勾选"自动刷新"或用户点击"刷新页面"按钮,执行页面刷新
|
||||
|
||||
**注意:** 当触发页面刷新时,popup 会自动关闭。需要在刷新前显示提示信息。
|
||||
|
||||
### 页面刷新
|
||||
|
||||
```typescript
|
||||
// 显示刷新提示
|
||||
setRefreshing(true);
|
||||
setTimeout(async () => {
|
||||
await chrome.tabs.reload(tab.id);
|
||||
}, 1500); // 1.5秒延迟让用户看到提示信息
|
||||
```
|
||||
|
||||
### 空状态处理
|
||||
|
||||
当所有存储类型清理返回 0 时,显示友好的提示:
|
||||
|
||||
```
|
||||
该页面没有可清理的存储数据
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
| 错误场景 | 处理方式 |
|
||||
| ------------------------------- | ---------------------------------------- |
|
||||
| 无法获取当前标签页 | 显示错误提示:"无法获取当前标签页" |
|
||||
| 受限页面(chrome://, about://) | 显示错误提示:"存储清理功能不支持此页面" |
|
||||
| 无法访问页面 URL | 显示错误提示:"无法访问此页面" |
|
||||
| IndexedDB onblocked | 显示警告但继续执行其他清理 |
|
||||
| IndexedDB.databases 不可用 | 使用降级方案或提示用户手动清除 |
|
||||
| 清理失败 | 显示具体错误信息 |
|
||||
| Cookies 删除失败 | 记录错误,显示清理失败提示 |
|
||||
| 无权限 | 提示用户刷新扩展或检查权限 |
|
||||
| 脚本注入失败 | 显示错误提示:"无法注入清理脚本" |
|
||||
|
||||
**Popup 生命周期说明:**
|
||||
|
||||
- Popup 在页面失去焦点时会关闭
|
||||
- 刷新页面后 Popup 会自动关闭
|
||||
- 需要在刷新前显示提示:"页面即将刷新,Popup 将关闭"
|
||||
|
||||
## 用户偏好持久化
|
||||
|
||||
使用现有的 `chromeStorage.ts` 工具保存用户偏好:
|
||||
|
||||
```typescript
|
||||
// 保存用户偏好
|
||||
await storageUtil.set('storageCleaner/preferences', {
|
||||
autoRefresh: true, // 默认勾选自动刷新
|
||||
selectedTypes: {
|
||||
// 可以保存用户上次选择的存储类型
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 读取用户偏好
|
||||
const preferences = await storageUtil.get('storageCleaner/preferences', {
|
||||
autoRefresh: true,
|
||||
selectedTypes: {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 权限需求
|
||||
|
||||
需要在 manifest 中添加 `cookies` 权限:
|
||||
|
||||
```typescript
|
||||
permissions: [
|
||||
'storage',
|
||||
'unlimitedStorage',
|
||||
'clipboardWrite',
|
||||
'activeTab',
|
||||
'scripting',
|
||||
'tabs',
|
||||
'debugger',
|
||||
'cookies', // 新增
|
||||
],
|
||||
```
|
||||
|
||||
## TypeScript 类型定义
|
||||
|
||||
在现有 `types/storage.d.ts` 中添加存储清理相关的类型:
|
||||
|
||||
```typescript
|
||||
export interface StorageSchema {
|
||||
'app/lastRoute': string;
|
||||
'app/theme': string;
|
||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
||||
}
|
||||
|
||||
export interface StorageCleanerPreferences {
|
||||
autoRefresh: boolean;
|
||||
selectedTypes: StorageCleanerOptions;
|
||||
}
|
||||
|
||||
export interface StorageCleanerOptions {
|
||||
localStorage: boolean;
|
||||
sessionStorage: boolean;
|
||||
indexedDB: boolean;
|
||||
cookies: boolean;
|
||||
cacheStorage: boolean;
|
||||
serviceWorkers: boolean;
|
||||
}
|
||||
|
||||
export type StorageCleanResult =
|
||||
| {
|
||||
success: true;
|
||||
count: number;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface CleaningResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
localStorage?: StorageCleanResult;
|
||||
sessionStorage?: StorageCleanResult;
|
||||
indexedDB?: StorageCleanResult;
|
||||
cookies?: StorageCleanResult;
|
||||
cacheStorage?: StorageCleanResult;
|
||||
serviceWorkers?: StorageCleanResult;
|
||||
}
|
||||
```
|
||||
|
||||
## 测试计划
|
||||
|
||||
1. 测试各种存储类型的单独清理
|
||||
2. 测试同时清理多种存储类型
|
||||
3. 测试自动刷新功能
|
||||
4. 测试手动刷新按钮
|
||||
5. 测试无存储数据时的清理(显示空状态提示)
|
||||
6. 测试无法访问页面的错误处理
|
||||
7. 测试受限页面(chrome://, about://, file://, data://)
|
||||
8. 测试 IndexedDB onblocked 场景
|
||||
9. 测试 httponly 和 secure cookies 清理
|
||||
10. 测试本地开发环境(localhost)
|
||||
11. 测试用户偏好持久化
|
||||
|
||||
## 后续优化
|
||||
|
||||
- 显示清理前的存储使用情况
|
||||
- 支持批量清理多个标签页
|
||||
- 支持自定义域名清理
|
||||
@@ -0,0 +1,135 @@
|
||||
# 为懒加载页面组件添加独立 ErrorBoundary 保护 — 设计文档
|
||||
|
||||
- 日期:2026-05-13
|
||||
- 范围:popup / sidepanel / tab / options 入口下的页面错误隔离
|
||||
|
||||
## 背景与目标
|
||||
|
||||
当前应用通过 `config/features.tsx` 中的 `React.lazy()` 懒加载所有页面组件。`RouterContainer` 使用 `Suspense` 包裹动态组件,而错误边界仅在外层 `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx` 中包裹 `RouterContainer`、以及 `entrypoints/options/App.tsx` 顶层。
|
||||
|
||||
问题:单个懒加载页面在加载或渲染时一旦抛错,错误会冒泡到全局 `ErrorBoundary`,触发全屏错误 UI,整个路由容器和 TopBar 一起被替换。用户必须刷新页面才能继续使用其他工具,体验受损。
|
||||
|
||||
目标:将错误影响范围限制在当前页面区域;其他页面、TopBar、导航、Snackbar 不受影响;用户可在错误状态下切换到其他工具或重试当前页。
|
||||
|
||||
## 设计概要
|
||||
|
||||
新增 `PageErrorBoundary` 组件,专门用于页面级错误隔离,在 `RouterContainer` 的 `Suspense` 内层使用;`options/App.tsx` 也替换为同款页面级边界。现有全局 `ErrorBoundary` 保留作为兜底,覆盖 TopBar / Snackbar / RouterProvider 等同级组件。
|
||||
|
||||
## 组件设计
|
||||
|
||||
### `components/PageErrorBoundary.tsx`(新增)
|
||||
|
||||
复用现有 `ErrorBoundary` 的错误捕获机制(`getDerivedStateFromError` + `componentDidCatch`),但具备以下差异:
|
||||
|
||||
- **轻量内嵌 UI**:使用 `Paper` + 居中文本,去除全屏 `Container` + `mt:8` 布局,适配 popup 400×600 与 sidepanel 等窄屏环境。结构:
|
||||
- 图标 (`ErrorOutlineIcon`)
|
||||
- 标题:"该页面加载失败"
|
||||
- 副标题:"页面在加载或渲染时遇到错误,您可以重试或切换到其他工具。"
|
||||
- 折叠错误信息块(沿用现有错误展示样式,使用 monospace、可滚动)
|
||||
- 主操作按钮:"重试"(`RefreshIcon`)—— 重置内部 state,让子树重新挂载
|
||||
- **`resetKey` prop**:可选;当 `resetKey` 在 `componentDidUpdate` 中变化时,自动重置 `hasError` / `error`,无需用户手动操作
|
||||
- **`componentDidCatch`**:仍使用 `console.error('Uncaught error in page:', error, errorInfo)` 输出,不引入额外上报
|
||||
|
||||
接口:
|
||||
```ts
|
||||
interface PageErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
resetKey?: string | number; // 变化时自动重置
|
||||
}
|
||||
```
|
||||
|
||||
### `components/ErrorBoundary.tsx`(不变)
|
||||
|
||||
保留作为全局兜底。负责捕获 TopBar、SnackbarProvider、RouterProvider 等同级组件中可能出现的错误,沿用全屏 `Container` 样式与"刷新应用"操作。
|
||||
|
||||
## 集成点
|
||||
|
||||
### `components/RouterContainer.tsx`(修改)
|
||||
|
||||
在 `Suspense` 内层插入 `PageErrorBoundary`,传入 `resetKey={currentPage}`:
|
||||
|
||||
```tsx
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<PageErrorBoundary resetKey={currentPage}>
|
||||
{Component && <Component />}
|
||||
</PageErrorBoundary>
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
说明:
|
||||
- `PageErrorBoundary` 置于 `Suspense` 内部,可同时捕获懒加载 chunk 加载失败(异步异常)与页面渲染期同步错误
|
||||
- `resetKey={currentPage}` 使页面切换时自动清除错误状态,无需用户干预
|
||||
- 保留外层 `Box key={currentPage}` 与动画 className,不改变页面切换语义
|
||||
|
||||
### `entrypoints/options/App.tsx`(修改)
|
||||
|
||||
将顶层 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`。options 是单页应用,统一使用页面级错误卡片即可。
|
||||
|
||||
### `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`(不变)
|
||||
|
||||
保留外层 `<ErrorBoundary>` 包裹 `<RouterContainer />`,作为同级组件(TopBar 等)的兜底。`PageErrorBoundary` 与全局 `ErrorBoundary` 各司其职:
|
||||
|
||||
- 页面级(`PageErrorBoundary`):捕获懒加载页面内部错误,隔离影响范围,仅替换页面区域
|
||||
- 全局(`ErrorBoundary`):捕获 RouterContainer 自身、TopBar、Snackbar 等组件错误,作为最后兜底
|
||||
|
||||
## 数据流 / 错误处理
|
||||
|
||||
### 捕获路径
|
||||
- 懒加载 chunk 加载失败(网络 / CSP / chunk 缺失) → `Suspense` 内部 promise reject → `PageErrorBoundary` 捕获
|
||||
- 页面渲染期同步错误(组件抛错、null 引用等) → `PageErrorBoundary` 捕获
|
||||
- 事件回调或 Promise 中的异步错误 → React 错误边界不捕获(固有行为,本次不处理)
|
||||
|
||||
### 恢复路径
|
||||
- **页面切换自动重置**:用户从错误页切换到其他页面 → `currentPage` 变化 → `resetKey` 变化 → `PageErrorBoundary.componentDidUpdate` 重置 → 新页面正常渲染
|
||||
- **当前页重试**:用户点击"重试" → 内部 state 重置 → 子树重新挂载 → React 重新触发 `lazy()` 加载(懒加载失败时也会重新发起 `import()`)
|
||||
- **持续错误**:若 `lazy()` chunk 始终无法加载(例如永久 404),重试会再次显示错误卡片;用户可切换到其他页面继续使用其他工具
|
||||
|
||||
### 日志
|
||||
沿用 `console.error`;不引入 Sentry / 外部上报。
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 新增 `components/__tests__/PageErrorBoundary.test.tsx`
|
||||
|
||||
1. 正常渲染:子组件正常渲染时,输出原始 children
|
||||
2. 同步错误捕获:子组件抛错时,显示错误卡片(标题"该页面加载失败"、错误信息)
|
||||
3. 重试按钮恢复:错误状态下,将 children 替换为正常组件,点击"重试"按钮,重置状态并显示正常内容
|
||||
4. `resetKey` 变化自动重置:错误状态下 `resetKey` 变化时,自动清空错误并渲染新 children
|
||||
5. `resetKey` 不变保持错误:children 变化但 `resetKey` 未变化时,保持错误状态(避免误重置)
|
||||
6. 错误信息显示:错误的 `toString()` 内容能在 UI 中可见
|
||||
|
||||
### 新增 `components/__tests__/RouterContainer.test.tsx`
|
||||
|
||||
- 通过 mock `useRouter` 与 `FEATURES`,注入一个会抛错的懒加载组件,验证 `PageErrorBoundary` 捕获错误且 TopBar / 父容器 DOM 仍存在
|
||||
- 切换 `currentPage`(重新触发 hook 返回值)后验证错误自动清除、新页面正常渲染
|
||||
|
||||
### 不变
|
||||
- `components/__tests__/ErrorBoundary.test.tsx` 不需改动(全局边界行为未变)
|
||||
|
||||
### 风格
|
||||
遵循现有 `ErrorBoundary.test.tsx` 模式:`vi.spyOn(console, 'error')` 抑制噪声 + `@testing-library/react` 的 `render` + `screen.getByText` 断言。
|
||||
|
||||
## 文件清单
|
||||
|
||||
**新增:**
|
||||
- `components/PageErrorBoundary.tsx`
|
||||
- `components/__tests__/PageErrorBoundary.test.tsx`
|
||||
- `components/__tests__/RouterContainer.test.tsx`
|
||||
|
||||
**修改:**
|
||||
- `components/RouterContainer.tsx` — 在 `Suspense` 内层包裹 `PageErrorBoundary resetKey={currentPage}`
|
||||
- `entrypoints/options/App.tsx` — 将 `<ErrorBoundary>` 替换为 `<PageErrorBoundary>`
|
||||
|
||||
**不变:**
|
||||
- `components/ErrorBoundary.tsx`
|
||||
- `components/__tests__/ErrorBoundary.test.tsx`
|
||||
- `entrypoints/popup/App.tsx`、`entrypoints/sidepanel/App.tsx`
|
||||
- `config/features.tsx`
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 单页面在懒加载或渲染时抛错,仅当前页面区域显示错误卡片,TopBar 与导航仍可点击
|
||||
2. 在错误状态下切换到其他工具,新页面能正常加载与显示
|
||||
3. 点击错误卡片中的"重试"按钮,子树重新挂载并重新触发懒加载
|
||||
4. 全局 `ErrorBoundary` 仍能捕获 TopBar / Snackbar 等同级组件的错误
|
||||
5. 所有新增测试通过;现有 `ErrorBoundary` 测试不受影响;`npm run compile`、`npm run lint`、`npm run test` 均通过
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+89
-38
@@ -1,51 +1,102 @@
|
||||
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';
|
||||
|
||||
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');
|
||||
// 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 tabs = await browser.tabs.query({});
|
||||
const { featureKey, payload } = result.data;
|
||||
|
||||
// 过滤不合法或受限制的 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));
|
||||
});
|
||||
try {
|
||||
// 检查侧边栏(Side Panel)的挂载激活状态
|
||||
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
|
||||
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
|
||||
|
||||
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);
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (isSidePanelOpen) {
|
||||
// 如果侧边栏正开着,利用高性能管道直发
|
||||
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug('[Context Menu] Side panel pipeline is not available:', err);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
||||
console.log(
|
||||
`Successfully injected content script into ${successCount}/${targetTabs.length} tabs.`,
|
||||
);
|
||||
// 💡 核心自愈机制:保存数据到共享沙箱 Storage,Popup 打开后(无论是自动还是手动)都会读取
|
||||
await saveContextMenuData({ featureKey, payload });
|
||||
|
||||
// 打开 popup 弹窗
|
||||
try {
|
||||
await browser.action.openPopup();
|
||||
} catch (err) {
|
||||
// 💡 修复点:自动打开 Popup 失败时,绝对不能将 pendingData 撕毁!
|
||||
// 保持数据留在 storage 内部,由于 Service Worker 的持久化,用户之后不管什么时候手动点开图标,
|
||||
// 数据依旧完好如初,完美契合了你的设计注释!
|
||||
console.warn(
|
||||
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留在内存中:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener((tabId) => {
|
||||
console.log('加载完成的 Tab ID:', tabId);
|
||||
// 监听扩展图标点击事件,安全激活侧边栏
|
||||
browser.action.onClicked.addListener(async (tab) => {
|
||||
if (tab.id) {
|
||||
try {
|
||||
await browser.sidePanel.open({ tabId: tab.id });
|
||||
} catch (err) {
|
||||
console.error('Failed to open side panel via extension action clicked:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 💡 3. 异步刷新请求监听
|
||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
||||
const { tabId, delay = 0 } = message.data;
|
||||
|
||||
const executeReload = () => {
|
||||
browser.tabs.reload(tabId).catch((err) => {
|
||||
console.error('Failed to execute tab reload operation:', err);
|
||||
});
|
||||
};
|
||||
|
||||
// 如果小于 1000ms(短抖动缓冲),可以使用极轻量级 setTimeout 防御
|
||||
// 如果是秒级以上的延时,为防止 Service Worker 闲置被内核销毁,应当使用 Alarms 沙箱驱动
|
||||
if (delay > 0 && delay < 1000) {
|
||||
setTimeout(executeReload, delay);
|
||||
} else if (delay >= 1000) {
|
||||
const alarmName = `reload-tab-${tabId}-${Date.now()}`;
|
||||
|
||||
// 创建一个临时的一次性 Alarm 闹钟
|
||||
await browser.alarms.create(alarmName, { when: Date.now() + delay });
|
||||
|
||||
// 动态注册一个一次性的生命周期续航守卫
|
||||
const alarmListener = (alarm: { name: string }) => {
|
||||
if (alarm.name === alarmName) {
|
||||
executeReload();
|
||||
browser.alarms.onAlarm.removeListener(alarmListener);
|
||||
}
|
||||
};
|
||||
browser.alarms.onAlarm.addListener(alarmListener);
|
||||
} else {
|
||||
executeReload();
|
||||
}
|
||||
|
||||
return { success: true, message: '刷新请求已通过常驻 Service Worker 安全隔离区' };
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import '../.wxt/types/imports.d.ts';
|
||||
import { initMessageHandler } from './content/messageHandler';
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ['<all_urls>'],
|
||||
runAt: 'document_start',
|
||||
runAt: 'document_end',
|
||||
main() {
|
||||
// Content script placeholder
|
||||
initMessageHandler();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ContextMenuClickedPayload } from '@/utils/messages';
|
||||
import { MessageAction, onMessage } from '@/utils/messages';
|
||||
import { getTextStats } from '@/utils/textStatistics';
|
||||
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
|
||||
|
||||
// 💡 1. 国际化超进化:对接 chrome.i18n 插件标准 API,如果环境不支持则安全降级,拒绝硬编码中文
|
||||
function getI18nText(key: string, fallback: string): string {
|
||||
if (typeof chrome !== 'undefined' && chrome.i18n) {
|
||||
return chrome.i18n.getMessage(key) || fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function convertTimestamp(input: string): string {
|
||||
const invalidText = getI18nText('invalidTimestamp', 'Invalid Timestamp');
|
||||
const num = Number(input.trim());
|
||||
|
||||
if (isNaN(num)) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
// 1e12 判定毫秒级/秒级时间戳兼容
|
||||
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
|
||||
|
||||
if (isNaN(d.getTime())) {
|
||||
return invalidText;
|
||||
}
|
||||
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const hours = String(d.getHours()).padStart(2, '0');
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0');
|
||||
|
||||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
let lastClickX = 0;
|
||||
let lastClickY = 0;
|
||||
|
||||
// 💡 使用 capture: true 确保在任何极其复杂的单页应用(SPA)中都能精准捕获右键坐标
|
||||
document.addEventListener(
|
||||
'contextmenu',
|
||||
(e) => {
|
||||
lastClickX = e.clientX;
|
||||
lastClickY = e.clientY;
|
||||
},
|
||||
{ capture: true, passive: true }, // 优化滚动与捕获性能
|
||||
);
|
||||
|
||||
export function initContextMenuHandler(): void {
|
||||
// 💡 2. 全局自净化大闸(Global Auto-Purge Grid):
|
||||
// 当用户在网页上进行左键点击、滚动视视口、或调整大小时,
|
||||
// 证明心流已经移开,自发隐退所有浮动的 Popover 弹窗,体验顺滑得丝丝入扣!
|
||||
const dismissPopover = (): void => {
|
||||
hidePopover();
|
||||
};
|
||||
|
||||
document.addEventListener('click', dismissPopover, { passive: true });
|
||||
document.addEventListener('scroll', dismissPopover, { passive: true });
|
||||
window.addEventListener('resize', dismissPopover, { passive: true });
|
||||
|
||||
onMessage(MessageAction.CONTEXT_MENU_CLICKED, (message) => {
|
||||
const { featureKey, payload } = message.data as ContextMenuClickedPayload;
|
||||
|
||||
switch (featureKey) {
|
||||
case 'timestamp': {
|
||||
const result = convertTimestamp(payload);
|
||||
showTimestampResult(lastClickX, lastClickY, payload, result);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'textStatistics': {
|
||||
const stats = getTextStats(payload);
|
||||
showTextStatsResult(lastClickX, lastClickY, payload, stats);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
hidePopover();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { initContextMenuHandler } from './contextMenuHandler';
|
||||
|
||||
export function initMessageHandler(): void {
|
||||
initContextMenuHandler();
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
const POPOVER_ID = 'testing-tools-popover';
|
||||
const POPOVER_STYLE_ID = 'testing-tools-popover-style';
|
||||
|
||||
function injectStyles(): void {
|
||||
if (document.getElementById(POPOVER_STYLE_ID)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = POPOVER_STYLE_ID;
|
||||
style.textContent = `
|
||||
#${POPOVER_ID} {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
max-width: 400px;
|
||||
min-width: 200px;
|
||||
padding: 12px 16px;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
opacity: 0;
|
||||
visibility: hidden; /* 💡 1. 规整隐藏状态:允许排版引擎计算尺寸,同时阻断视觉呈现 */
|
||||
transform: translateY(-8px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#${POPOVER_ID}.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-title {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: #a0a0b0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #808090;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-close:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-content {
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-label {
|
||||
color: #808090;
|
||||
font-size: 11px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .popover-value {
|
||||
color: #ffffff;
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
|
||||
font-size: 14px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .stat-item {
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .stat-label {
|
||||
font-size: 11px;
|
||||
color: #808090;
|
||||
}
|
||||
|
||||
#${POPOVER_ID} .stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getOrCreatePopover(): HTMLElement {
|
||||
let popover = document.getElementById(POPOVER_ID);
|
||||
if (!popover) {
|
||||
injectStyles();
|
||||
popover = document.createElement('div');
|
||||
popover.id = POPOVER_ID;
|
||||
document.body.appendChild(popover);
|
||||
}
|
||||
return popover;
|
||||
}
|
||||
|
||||
// 💡 2. 安全防线:字符实体转义沙箱,彻底掐灭任意恶意脚本的执行通道
|
||||
function escapeHtml(text: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
};
|
||||
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||
}
|
||||
|
||||
function positionPopover(popover: HTMLElement, x: number, y: number): void {
|
||||
// 此时借助 visibility: hidden,元素在隐藏状态下拥有真实的布局高宽
|
||||
const rect = popover.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let left = x + 8; // 微微追加水平偏置,防范直接遮挡用户的鼠标落点
|
||||
let top = y + 8;
|
||||
|
||||
if (left + rect.width > viewportWidth - 16) {
|
||||
left = viewportWidth - rect.width - 16;
|
||||
}
|
||||
if (left < 16) left = 16;
|
||||
|
||||
if (top + rect.height > viewportHeight - 16) {
|
||||
top = y - rect.height - 8;
|
||||
}
|
||||
if (top < 16) top = 16;
|
||||
|
||||
popover.style.left = `${left}px`;
|
||||
popover.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function showPopover(
|
||||
x: number,
|
||||
y: number,
|
||||
contentHtml: string,
|
||||
title?: string,
|
||||
duration: number = 5000,
|
||||
): void {
|
||||
const popover = getOrCreatePopover();
|
||||
|
||||
// 💡 3. 坚固的无障碍绑定:废除违规的行内 inline onclick,改用标准原生节点监听
|
||||
popover.innerHTML = '';
|
||||
|
||||
if (title) {
|
||||
const header = document.createElement('div');
|
||||
header.className = 'popover-header';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'popover-title';
|
||||
titleSpan.textContent = title; // ✅ 强安全性护航
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'popover-close';
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
popover.classList.remove('visible');
|
||||
});
|
||||
|
||||
header.appendChild(titleSpan);
|
||||
header.appendChild(closeBtn);
|
||||
popover.appendChild(header);
|
||||
}
|
||||
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'popover-content';
|
||||
contentContainer.innerHTML = contentHtml; // 内部拼装的方法已提前完成全消毒转义
|
||||
popover.appendChild(contentContainer);
|
||||
|
||||
// 提前移除激活类名,使 visibility: hidden 起效以供测量
|
||||
popover.classList.remove('visible');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
positionPopover(popover, x, y);
|
||||
popover.classList.add('visible');
|
||||
});
|
||||
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
|
||||
if (duration > 0) {
|
||||
hideTimeout = setTimeout(() => {
|
||||
hidePopover();
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function hidePopover(): void {
|
||||
const popover = document.getElementById(POPOVER_ID);
|
||||
if (popover) {
|
||||
popover.classList.remove('visible');
|
||||
}
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function showTimestampResult(x: number, y: number, timestamp: string, result: string): void {
|
||||
// 对外部传来的参数先全数塞入 escapeHtml 大闸进行纯氧化清洗
|
||||
const cleanTimestamp = escapeHtml(timestamp);
|
||||
const cleanResult = escapeHtml(result);
|
||||
|
||||
const content = `
|
||||
<div class="popover-label">输入时间戳</div>
|
||||
<div class="popover-value">${cleanTimestamp}</div>
|
||||
<div class="popover-label">转换结果</div>
|
||||
<div class="popover-value">${cleanResult}</div>
|
||||
`;
|
||||
showPopover(x, y, content, '⏰ 时间戳转换');
|
||||
}
|
||||
|
||||
export function showTextStatsResult(
|
||||
x: number,
|
||||
y: number,
|
||||
text: string,
|
||||
stats: { characters: number; words: number; lines: number; bytes: number },
|
||||
): void {
|
||||
const truncatedText = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||
// 对选中的脏文本先进行严格转义
|
||||
const cleanText = escapeHtml(truncatedText);
|
||||
|
||||
const content = `
|
||||
<div class="popover-label">选中文本</div>
|
||||
<div class="popover-value">${cleanText}</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字符</div>
|
||||
<div class="stat-value">${stats.characters}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">单词</div>
|
||||
<div class="stat-value">${stats.words}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">行数</div>
|
||||
<div class="stat-value">${stats.lines}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">字节</div>
|
||||
<div class="stat-value">${stats.bytes}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
showPopover(x, y, content, '📊 文本统计');
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { SyntheticEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { GripVertical, RefreshCw, Settings } from 'lucide-react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import type { PageType, StorageSchema } from '@/types/storage';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { PaletteColorKey } from '@/config/features';
|
||||
import {
|
||||
getAllFeatureKeys,
|
||||
getDefaultPageOrder,
|
||||
getDefaultVisibleFeatureKeys,
|
||||
getFeatureByKey,
|
||||
} from '@/config/features';
|
||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||
primary: '#1976d2',
|
||||
success: '#2e7d32',
|
||||
warning: '#e65100',
|
||||
error: '#c62828',
|
||||
secondary: '#9c27b0',
|
||||
info: '#0288d1',
|
||||
};
|
||||
|
||||
const getColorCode = (key: PaletteColorKey): string => PALETTE_COLORS[key];
|
||||
|
||||
const isValidPage = (page: unknown): page is PageType => {
|
||||
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
||||
};
|
||||
|
||||
const isValidPageList = (pages: unknown): pages is PageType[] => {
|
||||
return Array.isArray(pages) && pages.every(isValidPage);
|
||||
};
|
||||
|
||||
type WindowType = 'popup' | 'sidepanel' | 'tab';
|
||||
|
||||
interface SortableFeatureRowProps {
|
||||
pageKey: PageType;
|
||||
isLast: boolean;
|
||||
isChecked: boolean;
|
||||
isDisabled: boolean;
|
||||
onToggle: (key: PageType) => void;
|
||||
}
|
||||
|
||||
function SortableFeatureRow({
|
||||
pageKey,
|
||||
isLast,
|
||||
isChecked,
|
||||
isDisabled,
|
||||
onToggle,
|
||||
}: SortableFeatureRowProps) {
|
||||
const { t } = useTranslation(['features']);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: pageKey,
|
||||
});
|
||||
|
||||
const feature = getFeatureByKey(pageKey);
|
||||
if (!feature) return null;
|
||||
|
||||
const colorKey = feature.themeColorKey ?? 'primary';
|
||||
const colorCode = getColorCode(colorKey);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 1 : 'auto',
|
||||
position: 'relative' as const,
|
||||
backgroundColor: isDragging ? `${colorCode}0a` : 'transparent',
|
||||
boxShadow: isDragging ? '0 8px 20px rgba(0,0,0,0.08)' : 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center justify-between p-4 sm:p-5 transition-all duration-200 hover:bg-muted ${
|
||||
isLast ? '' : 'border-b border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 sm:gap-4 flex-1 min-w-0">
|
||||
{/* 拖拽手柄 */}
|
||||
<div
|
||||
className="drag-handle text-muted-foreground cursor-grab touch-none transition-colors duration-200 hover:text-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`拖拽以调整 ${t(feature.labelKey)} 的位置`}
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
|
||||
{/* 功能图标 */}
|
||||
<div
|
||||
className="flex items-center justify-center w-9 h-9 rounded-xl flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: `${colorCode}1a`,
|
||||
color: colorCode,
|
||||
}}
|
||||
>
|
||||
{feature.icon && <feature.icon size={20} />}
|
||||
</div>
|
||||
|
||||
{/* 文本信息 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-bold text-[0.95rem] leading-tight text-foreground">
|
||||
{t(feature.labelKey)}
|
||||
</div>
|
||||
{feature.descriptionKey && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-medium mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
title={t(feature.descriptionKey)}
|
||||
>
|
||||
{t(feature.descriptionKey)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 开关 */}
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isChecked}
|
||||
disabled={isDisabled}
|
||||
onClick={() => onToggle(pageKey)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
|
||||
isChecked ? 'bg-primary' : 'bg-muted'
|
||||
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-200 ${
|
||||
isChecked ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation(['features', 'common']);
|
||||
|
||||
const initialWindowType = useMemo(() => {
|
||||
if (typeof window === 'undefined') return 'popup';
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tab = params.get('tab');
|
||||
if (tab === 'popup' || tab === 'sidepanel' || tab === 'tab') {
|
||||
return tab as WindowType;
|
||||
}
|
||||
return 'popup';
|
||||
}, []);
|
||||
|
||||
const [windowType, setWindowType] = useState<WindowType>(initialWindowType);
|
||||
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
||||
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { snackbarProps, showMessage } = useSnackbarState();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const configKeys = useMemo(() => {
|
||||
switch (windowType) {
|
||||
case 'sidepanel':
|
||||
return {
|
||||
visible: 'app/sidepanelVisiblePages' as keyof StorageSchema,
|
||||
order: 'app/sidepanelPageOrder' as keyof StorageSchema,
|
||||
};
|
||||
case 'tab':
|
||||
return {
|
||||
visible: 'app/tabVisiblePages' as keyof StorageSchema,
|
||||
order: 'app/tabPageOrder' as keyof StorageSchema,
|
||||
};
|
||||
case 'popup':
|
||||
default:
|
||||
return {
|
||||
visible: 'app/popupVisiblePages' as keyof StorageSchema,
|
||||
order: 'app/popupPageOrder' as keyof StorageSchema,
|
||||
};
|
||||
}
|
||||
}, [windowType]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('tab', windowType);
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}, [windowType]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
setIsLoaded(false);
|
||||
try {
|
||||
const [savedVisible, savedOrder] = await Promise.all([
|
||||
storageUtil.get(configKeys.visible, getDefaultVisibleFeatureKeys()),
|
||||
storageUtil.get(configKeys.order, getDefaultPageOrder()),
|
||||
]);
|
||||
setVisiblePages(
|
||||
isValidPageList(savedVisible) ? savedVisible : getDefaultVisibleFeatureKeys(),
|
||||
);
|
||||
setPageOrder(isValidPageList(savedOrder) ? savedOrder : getDefaultPageOrder());
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error);
|
||||
setVisiblePages(getDefaultVisibleFeatureKeys());
|
||||
setPageOrder(getDefaultPageOrder());
|
||||
} finally {
|
||||
setIsLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig().catch(console.error);
|
||||
}, [configKeys]);
|
||||
|
||||
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
||||
showMessage(message, { severity });
|
||||
};
|
||||
|
||||
const handlePageToggle = async (page: PageType) => {
|
||||
const isCurrentlyVisible = visiblePages.includes(page);
|
||||
let newPages: PageType[];
|
||||
|
||||
if (isCurrentlyVisible) {
|
||||
if (visiblePages.length <= 1) {
|
||||
showToast('至少需要保留一个可见页面', 'warning');
|
||||
return;
|
||||
}
|
||||
newPages = visiblePages.filter((p) => p !== page);
|
||||
} else {
|
||||
newPages = [...visiblePages, page];
|
||||
}
|
||||
|
||||
try {
|
||||
await storageUtil.set(configKeys.visible, newPages);
|
||||
setVisiblePages(newPages);
|
||||
const feature = getFeatureByKey(page);
|
||||
const label = feature ? t(feature.labelKey) : page;
|
||||
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${label}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error);
|
||||
showToast('保存失败', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = pageOrder.indexOf(active.id as PageType);
|
||||
const newIndex = pageOrder.indexOf(over.id as PageType);
|
||||
if (oldIndex < 0 || newIndex < 0) return;
|
||||
|
||||
const newOrder = arrayMove(pageOrder, oldIndex, newIndex);
|
||||
setPageOrder(newOrder);
|
||||
|
||||
try {
|
||||
await storageUtil.set(configKeys.order, newOrder);
|
||||
} catch (error) {
|
||||
console.error('Failed to save order:', error);
|
||||
showToast('排序保存失败', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefaults = async () => {
|
||||
try {
|
||||
const defaults = getDefaultVisibleFeatureKeys();
|
||||
const defaultOrder = getDefaultPageOrder();
|
||||
|
||||
await Promise.all([
|
||||
storageUtil.set(configKeys.visible, defaults),
|
||||
storageUtil.set(configKeys.order, defaultOrder),
|
||||
]);
|
||||
|
||||
setVisiblePages(defaults);
|
||||
setPageOrder(defaultOrder);
|
||||
showToast('已恢复当前模式默认设置', 'success');
|
||||
} catch (error) {
|
||||
console.error('Failed to restore defaults:', error);
|
||||
showToast('恢复失败', 'warning');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowTypeChange = (_event: SyntheticEvent, newType: WindowType) => {
|
||||
if (newType !== null) {
|
||||
setWindowType(newType);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app min-h-screen bg-background flex flex-col">
|
||||
<PageErrorBoundary>
|
||||
{/* 顶部标题与导航栏 */}
|
||||
<div className="w-full bg-background border-b border-border pt-8 sm:pt-12 pb-0 px-4 sm:px-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<PageHeader
|
||||
icon={<Settings size={20} />}
|
||||
iconColor="#1976d2"
|
||||
title="应用设置"
|
||||
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
|
||||
/>
|
||||
{/* Tab 与恢复按钮同行 */}
|
||||
<div className="flex items-end justify-between border-b-0">
|
||||
<div className="flex-1 flex">
|
||||
{(['popup', 'sidepanel', 'tab'] as WindowType[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={(e) => handleWindowTypeChange(e, type)}
|
||||
className={`px-4 sm:px-6 py-2 text-[0.9rem] font-bold transition-colors duration-200 ${
|
||||
windowType === type
|
||||
? 'text-primary border-b-2 border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{type === 'popup' ? 'Popup 窗口' : type === 'sidepanel' ? '侧边栏' : '标签页'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRestoreDefaults}
|
||||
className="mb-1 ml-2 p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors duration-200"
|
||||
title="恢复当前模式默认"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 p-4 sm:p-8">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="rounded-2xl border border-border overflow-hidden bg-card shadow-sm">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={pageOrder} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col">
|
||||
{pageOrder.map((key, index, array) => {
|
||||
const isChecked = visiblePages.includes(key);
|
||||
const isDisabled = isChecked && visiblePages.length === 1;
|
||||
return (
|
||||
<SortableFeatureRow
|
||||
key={key}
|
||||
pageKey={key}
|
||||
isLast={index === array.length - 1}
|
||||
isChecked={isChecked}
|
||||
isDisabled={isDisabled}
|
||||
onToggle={handlePageToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageErrorBoundary>
|
||||
|
||||
<GlobalSnackbar {...snackbarProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import App from '../App';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import {
|
||||
getDefaultVisibleFeatureKeys,
|
||||
getDefaultPageOrder,
|
||||
getFeatureByKey,
|
||||
} from '@/config/features';
|
||||
|
||||
// Mock storageUtil
|
||||
vi.mock('@/utils/chromeStorage', () => ({
|
||||
storageUtil: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Options App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('合法数据应正常加载并显示正确的功能列表', async () => {
|
||||
const defaultOrder = getDefaultPageOrder();
|
||||
|
||||
(storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) =>
|
||||
Promise.resolve(defaultValue),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 验证默认可见的功能都被渲染出来了
|
||||
for (const key of defaultOrder) {
|
||||
const feature = getFeatureByKey(key);
|
||||
if (feature) {
|
||||
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('非法 visiblePages 数据应回退到默认值', async () => {
|
||||
const defaultVisible = getDefaultVisibleFeatureKeys();
|
||||
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key.includes('VisiblePages')) {
|
||||
return Promise.resolve(['invalidPage', 'anotherInvalid']);
|
||||
}
|
||||
return Promise.resolve(defaultValue);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 验证默认值的功能仍然被渲染(非法数据被回退)
|
||||
for (const key of defaultVisible) {
|
||||
const feature = getFeatureByKey(key);
|
||||
if (feature && key !== 'dashboard') {
|
||||
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('非法 pageOrder 数据应回退到默认值', async () => {
|
||||
const defaultOrder = getDefaultPageOrder();
|
||||
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key.includes('PageOrder')) {
|
||||
return Promise.resolve(['notARealPage', 123, null]);
|
||||
}
|
||||
return Promise.resolve(defaultValue);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 验证默认顺序的功能都被渲染
|
||||
for (const key of defaultOrder) {
|
||||
const feature = getFeatureByKey(key);
|
||||
if (feature) {
|
||||
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('非数组数据应回退到默认值', async () => {
|
||||
const defaultOrder = getDefaultPageOrder();
|
||||
|
||||
(storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => {
|
||||
if (key.includes('VisiblePages')) {
|
||||
return Promise.resolve('not-an-array');
|
||||
}
|
||||
if (key.includes('PageOrder')) {
|
||||
return Promise.resolve({ foo: 'bar' });
|
||||
}
|
||||
return Promise.resolve(defaultValue);
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 验证默认功能都被渲染
|
||||
for (const key of defaultOrder) {
|
||||
const feature = getFeatureByKey(key);
|
||||
if (feature) {
|
||||
expect(screen.getByText(feature.labelKey)).toBeInTheDocument();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Document</title>
|
||||
<title>扩展设置 - Testing Tools</title>
|
||||
</head>
|
||||
<body>
|
||||
Hello Options Page
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AppRoot from '@/providers/AppRoot';
|
||||
import '@/i18n';
|
||||
import '@/src/index.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<AppRoot>
|
||||
<App />
|
||||
</AppRoot>,
|
||||
);
|
||||
@@ -1,79 +0,0 @@
|
||||
/* 隐藏页面滚动条 */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* 隐藏body滚动条但允许滚动 */
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 统一圆角变量 */
|
||||
--radius: 8px;
|
||||
/* 统一背景颜色变量 */
|
||||
--bg-color: #fafafa;
|
||||
/* 统一文字颜色变量 */
|
||||
--text-color: #333333;
|
||||
/* 统一按钮颜色变量 */
|
||||
--btn-bg: #e0e0e0;
|
||||
/* 统一按钮文字颜色变量 */
|
||||
--btn-text: #333333;
|
||||
--border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.app {
|
||||
width: 400px;
|
||||
min-height: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 导航容器 */
|
||||
.nav-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 导航按钮 */
|
||||
.nav-button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--btn-bg);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--btn-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: var(--btn-bg);
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background: var(--btn-bg);
|
||||
font-weight: 500;
|
||||
}
|
||||
+39
-80
@@ -1,88 +1,47 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { 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';
|
||||
import RouterProvider from '@/providers/RouterProvider';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||
import { getEntryPointType } from '@/config/features';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function App() {
|
||||
const handleOpenOptions = () => {
|
||||
chrome.runtime.openOptionsPage().catch(console.error);
|
||||
};
|
||||
|
||||
const PAGE_CONFIG = {
|
||||
timestamp: { label: '时间戳', defaultVisible: true },
|
||||
storageCleaner: { label: '存储清理', defaultVisible: true },
|
||||
} as const satisfies Record<PageType, { label: string; defaultVisible: boolean }>;
|
||||
const entryType = useMemo(() => getEntryPointType(), []);
|
||||
|
||||
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);
|
||||
const routerConfig = useMemo(() => {
|
||||
if (entryType === 'tab') {
|
||||
return {
|
||||
syncKey: 'app/tabRoute' as const,
|
||||
visiblePagesKey: 'app/tabVisiblePages' as const,
|
||||
pageOrderKey: 'app/tabPageOrder' as const,
|
||||
};
|
||||
}
|
||||
}, [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 {
|
||||
syncKey: 'app/popupRoute' as const,
|
||||
visiblePagesKey: 'app/popupVisiblePages' as const,
|
||||
pageOrderKey: 'app/popupPageOrder' as const,
|
||||
};
|
||||
}, [entryType]);
|
||||
|
||||
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>
|
||||
<RouterProvider
|
||||
syncKey={routerConfig.syncKey}
|
||||
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||
pageOrderKey={routerConfig.pageOrderKey}
|
||||
>
|
||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||
<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 onOpenOptions={handleOpenOptions} />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</SnackbarProvider>
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -3,8 +3,31 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Default Popup Title</title>
|
||||
<title>Testing Tools - 标签页</title>
|
||||
<meta name="manifest.type" content="browser_action" />
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AppRoot from '@/providers/AppRoot';
|
||||
import '@/i18n';
|
||||
import '@/src/index.css';
|
||||
import App from './App.tsx';
|
||||
import './style.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AppRoot>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
</AppRoot>,
|
||||
);
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
Typography,
|
||||
Box,
|
||||
Checkbox,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Alert,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import type { StorageCleanerOptions, CleaningResult, StorageCleanerPreferences } from 'types/storage';
|
||||
import {
|
||||
getCurrentTab,
|
||||
isRestrictedUrl,
|
||||
clearStorage,
|
||||
formatCleaningResult,
|
||||
} from '@/utils/storageCleaner';
|
||||
|
||||
const DEFAULT_OPTIONS: StorageCleanerOptions = {
|
||||
localStorage: true,
|
||||
sessionStorage: true,
|
||||
indexedDB: true,
|
||||
cookies: true,
|
||||
cacheStorage: true,
|
||||
serviceWorkers: true,
|
||||
};
|
||||
|
||||
const DEFAULT_PREFERENCES: StorageCleanerPreferences = {
|
||||
autoRefresh: true,
|
||||
selectedTypes: DEFAULT_OPTIONS,
|
||||
};
|
||||
|
||||
export default function StorageCleanerPage() {
|
||||
const [domain, setDomain] = useState<string>('');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [options, setOptions] = useState<StorageCleanerOptions>(DEFAULT_OPTIONS);
|
||||
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [result, setResult] = useState<CleaningResult | null>(null);
|
||||
const [showConfirm, setShowConfirm] = useState<boolean>(false);
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({
|
||||
open: false,
|
||||
message: '',
|
||||
});
|
||||
|
||||
// Load tab info and user preferences
|
||||
useEffect(() => {
|
||||
const loadInfo = async () => {
|
||||
const tab = await getCurrentTab();
|
||||
|
||||
if (!tab || !tab.url) {
|
||||
setError('无法获取当前标签页');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRestrictedUrl(tab.url)) {
|
||||
setError('存储清理功能不支持此页面');
|
||||
return;
|
||||
}
|
||||
|
||||
setDomain(new URL(tab.url).hostname);
|
||||
|
||||
// Load user preferences
|
||||
const prefs = await storageUtil.get(
|
||||
'storageCleaner/preferences',
|
||||
DEFAULT_PREFERENCES,
|
||||
);
|
||||
setAutoRefresh(prefs?.autoRefresh ?? DEFAULT_PREFERENCES.autoRefresh);
|
||||
setOptions(prefs?.selectedTypes ?? DEFAULT_PREFERENCES.selectedTypes);
|
||||
};
|
||||
|
||||
loadInfo();
|
||||
}, []);
|
||||
|
||||
const handleOptionChange = useCallback((key: keyof StorageCleanerOptions) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const handleClean = useCallback(async () => {
|
||||
const tab = await getCurrentTab();
|
||||
|
||||
if (!tab || !tab.id || !tab.url) {
|
||||
setSnackbar({ open: true, message: '无法获取当前标签页' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const cleaningResult = await clearStorage(tab.id, tab.url, options);
|
||||
setResult(cleaningResult);
|
||||
|
||||
// Save user preferences
|
||||
await storageUtil.set('storageCleaner/preferences', {
|
||||
autoRefresh,
|
||||
selectedTypes: options,
|
||||
});
|
||||
|
||||
// Auto refresh if enabled
|
||||
if (autoRefresh && cleaningResult.success && tab.id !== undefined) {
|
||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
||||
setTimeout(() => {
|
||||
chrome.tabs.reload(tab.id!);
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: `清理失败: ${String(err)}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowConfirm(false);
|
||||
}
|
||||
}, [options, autoRefresh]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
const tab = await getCurrentTab();
|
||||
if (tab?.id !== undefined) {
|
||||
setSnackbar({ open: true, message: '页面即将刷新,Popup 将关闭' });
|
||||
setTimeout(() => {
|
||||
chrome.tabs.reload(tab.id!);
|
||||
}, 1500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
||||
<Alert severity="error" icon={<WarningIcon />}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2, m: 1, borderRadius: 2 }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 2 }}>
|
||||
<Typography variant="h5" component="h1" sx={{ mb: 1 }}>
|
||||
存储清理
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
当前页面: {domain || '加载中...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Storage Type Options */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>
|
||||
选择要清理的存储类型:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.localStorage}
|
||||
onChange={() => handleOptionChange('localStorage')}
|
||||
/>
|
||||
}
|
||||
label="localStorage"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.sessionStorage}
|
||||
onChange={() => handleOptionChange('sessionStorage')}
|
||||
/>
|
||||
}
|
||||
label="sessionStorage"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.indexedDB}
|
||||
onChange={() => handleOptionChange('indexedDB')}
|
||||
/>
|
||||
}
|
||||
label="IndexedDB"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.cookies}
|
||||
onChange={() => handleOptionChange('cookies')}
|
||||
/>
|
||||
}
|
||||
label="Cookies"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.cacheStorage}
|
||||
onChange={() => handleOptionChange('cacheStorage')}
|
||||
/>
|
||||
}
|
||||
label="Cache Storage"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={options.serviceWorkers}
|
||||
onChange={() => handleOptionChange('serviceWorkers')}
|
||||
/>
|
||||
}
|
||||
label="Service Workers"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Auto Refresh Option */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="清理完成后自动刷新页面"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{loading ? '清理中...' : '清理'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Result Display */}
|
||||
{result && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Alert
|
||||
severity={result.success ? 'success' : 'error'}
|
||||
sx={{ mb: !autoRefresh && result.success ? 1 : 0 }}
|
||||
>
|
||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
||||
</Alert>
|
||||
{!autoRefresh && result.success && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={handleRefresh}
|
||||
fullWidth
|
||||
>
|
||||
刷新页面
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{showConfirm && (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bgcolor: 'rgba(255,255,255, 0.95)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">确认清理</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mb: 1 }}>
|
||||
将清理以下存储类型:
|
||||
</Typography>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
{options.localStorage && (
|
||||
<Typography variant="body2">- localStorage</Typography>
|
||||
)}
|
||||
{options.sessionStorage && (
|
||||
<Typography variant="body2">- sessionStorage</Typography>
|
||||
)}
|
||||
{options.indexedDB && <Typography variant="body2">- IndexedDB</Typography>}
|
||||
{options.cookies && <Typography variant="body2">- Cookies</Typography>}
|
||||
{options.cacheStorage && (
|
||||
<Typography variant="body2">- Cache Storage</Typography>
|
||||
)}
|
||||
{options.serviceWorkers && (
|
||||
<Typography variant="body2">- Service Workers</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: 'center', mb: 1 }}
|
||||
>
|
||||
此操作不可撤销。
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="outlined" onClick={() => setShowConfirm(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="contained" color="error" onClick={handleClean}>
|
||||
确认清理
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Snackbar */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
>
|
||||
<Alert severity="info" variant="filled">
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import dayjs from '@/utils/dayjs';
|
||||
import {
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
IconButton,
|
||||
Snackbar,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
alpha,
|
||||
Tooltip,
|
||||
Theme,
|
||||
} from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||
|
||||
// ================= 常量配置 =================
|
||||
const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
||||
const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
||||
|
||||
type UnitType = 'ms' | 's';
|
||||
type ZoneType = (typeof ZONES)[number];
|
||||
|
||||
const INPUT_STYLE = {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
bgcolor: 'grey.50',
|
||||
borderRadius: 3,
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'& fieldset': { border: 'none' },
|
||||
'&:hover': { bgcolor: 'grey.100' },
|
||||
'&.Mui-focused': {
|
||||
bgcolor: '#fff',
|
||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}, 0 4px 12px rgba(0,0,0,0.03)`,
|
||||
},
|
||||
'&.Mui-error': {
|
||||
boxShadow: (theme: Theme) => `0 0 0 2px ${alpha(theme.palette.error.main, 0.2)}`,
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': { py: 1.5, fontFamily: 'monospace' },
|
||||
};
|
||||
|
||||
// ================= 子组件:实时时钟 =================
|
||||
interface LiveClockProps {
|
||||
unit: UnitType;
|
||||
onCopy: (val: string) => void;
|
||||
onUseNow: (val: number) => void;
|
||||
}
|
||||
|
||||
const LiveClock = React.memo(({
|
||||
unit,
|
||||
onCopy,
|
||||
onUseNow
|
||||
}: LiveClockProps) => {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const displayVal = useMemo(() =>
|
||||
String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
||||
[now, unit]);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 4 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
<Typography variant="h5" sx={{ fontWeight: 300, letterSpacing: '-1px', color: 'text.primary', fontFamily: 'monospace' }}>
|
||||
{displayVal}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
{unit}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="填充到下方">
|
||||
<IconButton
|
||||
aria-label="use current time"
|
||||
size="small"
|
||||
onClick={() => onUseNow(now)}
|
||||
sx={{ color: 'primary.main', transition: 'all 0.2s', '&:hover': { bgcolor: alpha('#2563eb', 0.08) } }}
|
||||
>
|
||||
<AccessTimeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="复制当前时间戳">
|
||||
<IconButton
|
||||
aria-label="copy current timestamp"
|
||||
size="small"
|
||||
onClick={() => onCopy(displayVal)}
|
||||
sx={{ color: 'grey.400', transition: 'all 0.2s', '&:hover': { color: 'primary.main', transform: 'scale(1.1)' } }}
|
||||
>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
LiveClock.displayName = 'LiveClock';
|
||||
|
||||
// ================= 子组件:多维度结果展示 =================
|
||||
interface ResultViewProps {
|
||||
result: string;
|
||||
mode: 'ts2dt' | 'dt2ts';
|
||||
unit: UnitType;
|
||||
zone: string;
|
||||
onCopy: (val: string) => void;
|
||||
}
|
||||
|
||||
const ResultView = React.memo(({
|
||||
result,
|
||||
mode,
|
||||
unit,
|
||||
zone,
|
||||
onCopy
|
||||
}: ResultViewProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
onCopy(result);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [onCopy, result]);
|
||||
|
||||
const extraInfo = useMemo(() => {
|
||||
if (!result) return null;
|
||||
const d = mode === 'ts2dt' ? dayjs(result, DATE_FORMAT).tz(zone) : (unit === 'ms' ? dayjs(Number(result)) : dayjs.unix(Number(result)));
|
||||
|
||||
return {
|
||||
relative: d.fromNow(),
|
||||
iso: d.toISOString(),
|
||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
||||
};
|
||||
}, [result, mode, zone, unit]);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
mt: 3, pt: 3, borderTop: '1px solid', borderColor: 'grey.50',
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
'@keyframes fadeIn': { from: { opacity: 0, transform: 'translateY(10px)' }, to: { opacity: 1, transform: 'translateY(0)' } }
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.disabled', mb: 1, display: 'block', ml: 1, fontWeight: 500 }}>
|
||||
转换结果
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={result}
|
||||
slotProps={{
|
||||
input: {
|
||||
readOnly: true,
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="copy result"
|
||||
size="small"
|
||||
onClick={handleCopy}
|
||||
sx={{
|
||||
color: copied ? 'success.main' : 'primary.main',
|
||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
transform: copied ? 'scale(1.2)' : 'scale(1)',
|
||||
}}
|
||||
>
|
||||
{copied ? <CheckIcon fontSize="small" /> : <ContentCopyIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
...INPUT_STYLE,
|
||||
mb: 2,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
...INPUT_STYLE['& .MuiOutlinedInput-root'],
|
||||
bgcolor: alpha('#2563eb', 0.03),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 辅助信息预览 */}
|
||||
<Stack spacing={1} sx={{ px: 1 }}>
|
||||
{[
|
||||
{ label: '相对时间', value: extraInfo?.relative },
|
||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
||||
].map((item) => (
|
||||
<Box key={item.label} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{item.label}</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
onClick={() => { if (item.value) onCopy(item.value); }}
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: 'text.primary',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'primary.main', textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
ResultView.displayName = 'ResultView';
|
||||
|
||||
// ================= 主页面组件 =================
|
||||
export default function TimestampPage() {
|
||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
||||
const [unit, setUnit] = useState<UnitType>('ms');
|
||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
||||
const [result, setResult] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [snack, setSnack] = useState<{ open: boolean; msg: string }>({ open: false, msg: '' });
|
||||
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setSnack({ open: true, msg: '已复制' });
|
||||
} catch {
|
||||
setSnack({ open: true, msg: '复制失败' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const convert = useCallback(() => {
|
||||
if (mode === 'ts2dt') {
|
||||
const rawInput = tsInput.trim();
|
||||
if (!rawInput) return;
|
||||
const num = Number(rawInput);
|
||||
if (isNaN(num)) { setError('无效数字'); return; }
|
||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
||||
if (!d.isValid()) { setError('无效时间戳'); return; }
|
||||
setError('');
|
||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
||||
} else {
|
||||
const rawInput = dtInput.trim();
|
||||
if (!rawInput) return;
|
||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
||||
if (!d.isValid()) { setError('格式错误'); return; }
|
||||
setError('');
|
||||
const ms = d.valueOf();
|
||||
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
||||
}
|
||||
}, [mode, tsInput, dtInput, unit, zone]);
|
||||
|
||||
// 智能实时转换 (Debounce Effect)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
convert();
|
||||
}, 400);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [convert]);
|
||||
|
||||
const handleUseNow = useCallback((now: number) => {
|
||||
if (mode === 'ts2dt') {
|
||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
||||
} else {
|
||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
||||
}
|
||||
}, [mode, unit, zone]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 1, width: '100%', bgcolor: 'transparent', boxSizing: 'border-box' }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': { boxShadow: '0 12px 40px rgba(0,0,0,0.06)', borderColor: 'grey.200' },
|
||||
}}
|
||||
>
|
||||
{/* 1. 实时时钟 */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LiveClock unit={unit} onCopy={copy} onUseNow={handleUseNow} />
|
||||
<Tooltip title="切换单位">
|
||||
<IconButton
|
||||
aria-label="switch unit"
|
||||
size="small"
|
||||
onClick={() => { setUnit((u) => (u === 'ms' ? 's' : 'ms')); }}
|
||||
sx={{
|
||||
position: 'absolute', right: 80, top: 4, color: 'grey.400',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': { transform: 'rotate(180deg)', color: 'primary.main' }
|
||||
}}
|
||||
>
|
||||
<SwapHorizIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* 2. 模式切换 */}
|
||||
<Box sx={{ position: 'relative', display: 'flex', p: 0.5, bgcolor: 'grey.100', borderRadius: 3.5, mb: 3, overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute', height: 'calc(100% - 8px)', width: 'calc(50% - 4px)',
|
||||
bgcolor: '#fff', borderRadius: 3, boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
||||
top: 4, left: 4,
|
||||
}}
|
||||
/>
|
||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
||||
<Button
|
||||
key={m} fullWidth disableRipple
|
||||
onClick={() => { setMode(m); setError(''); setResult(''); }}
|
||||
sx={{
|
||||
position: 'relative', zIndex: 1, borderRadius: 3, py: 1, textTransform: 'none',
|
||||
fontSize: '0.875rem', fontWeight: 500, transition: 'color 0.2s',
|
||||
color: mode === m ? 'text.primary' : 'text.disabled',
|
||||
'&:hover': { bgcolor: 'transparent', color: mode === m ? 'text.primary' : 'text.secondary' },
|
||||
}}
|
||||
>
|
||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 3. 输入与设置 */}
|
||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
||||
<TextField
|
||||
placeholder={mode === 'ts2dt' ? "输入时间戳..." : DATE_FORMAT}
|
||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (mode === 'ts2dt') {
|
||||
setTsInput(val);
|
||||
} else {
|
||||
setDtInput(val);
|
||||
}
|
||||
setError('');
|
||||
}}
|
||||
error={!!error}
|
||||
helperText={error}
|
||||
fullWidth
|
||||
sx={INPUT_STYLE}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Select
|
||||
fullWidth value={unit}
|
||||
onChange={(e) => { setUnit(e.target.value as UnitType); }}
|
||||
sx={{ ...INPUT_STYLE, flex: 1 }}
|
||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
||||
>
|
||||
<MenuItem value="ms">毫秒 (ms)</MenuItem>
|
||||
<MenuItem value="s">秒 (s)</MenuItem>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
fullWidth value={zone}
|
||||
onChange={(e) => { setZone(e.target.value as ZoneType); }}
|
||||
sx={{ ...INPUT_STYLE, flex: 1.5 }}
|
||||
MenuProps={{ PaperProps: { sx: { borderRadius: 3, mt: 1, boxShadow: '0 10px 40px rgba(0,0,0,0.1)' } } }}
|
||||
>
|
||||
{ZONES.map((z) => (
|
||||
<MenuItem key={z} value={z}>{z}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* 4. 转换操作 (作为手动确认) */}
|
||||
<Button
|
||||
fullWidth variant="contained" disableElevation disableRipple
|
||||
onClick={convert}
|
||||
sx={{
|
||||
py: 1.6, borderRadius: 3, fontSize: '1rem', fontWeight: 600, textTransform: 'none',
|
||||
bgcolor: 'primary.main', transition: 'all 0.2s',
|
||||
'&:hover': { bgcolor: 'primary.dark', transform: 'translateY(-1px)' },
|
||||
'&:active': { transform: 'translateY(0)' }
|
||||
}}
|
||||
>
|
||||
立即转换
|
||||
</Button>
|
||||
|
||||
{/* 5. 结果展示 */}
|
||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} onCopy={copy} />
|
||||
</Paper>
|
||||
|
||||
<Snackbar
|
||||
open={snack.open} autoHideDuration={1500}
|
||||
onClose={() => { setSnack((s) => ({ ...s, open: false })); }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity="success" variant="filled" icon={false} sx={{ borderRadius: 2.5, bgcolor: 'grey.900' }}>
|
||||
{snack.msg}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit; /* 先全部重置为继承大小 */
|
||||
font-weight: inherit;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect } from 'react';
|
||||
import RouterProvider from '@/providers/RouterProvider';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import RouterContainer from '@/components/RouterContainer';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||
|
||||
export default function App() {
|
||||
const handleOpenOptions = () => {
|
||||
chrome.runtime.openOptionsPage().catch((err) => {
|
||||
console.error('Failed to open options page:', err);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
|
||||
return () => {
|
||||
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
||||
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||
<TopBar onOpenOptions={handleOpenOptions} />
|
||||
<ErrorBoundary>
|
||||
<RouterContainer />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</SnackbarProvider>
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Testing Tools - Side Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AppRoot from '@/providers/AppRoot';
|
||||
import '@/i18n';
|
||||
import '@/src/index.css';
|
||||
import App from './App.tsx';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<AppRoot>
|
||||
<App />
|
||||
</AppRoot>,
|
||||
);
|
||||
+45
-8
@@ -1,12 +1,32 @@
|
||||
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__/**'] },
|
||||
// 1. 全局物理隔离:彻底掐灭对构建产物与配置本身的干扰
|
||||
{
|
||||
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||
},
|
||||
|
||||
// 2. 注入 JavaScript 与 TypeScript 的官方大师级推荐规则集
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// 3. 针对测试文件专属沙箱:解耦强类型死锁,放行 any,容忍未消费变量
|
||||
{
|
||||
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 4. 核心业务全受控大管线(Hooks, Entrypoints, Components 统一护航)
|
||||
{
|
||||
files: [
|
||||
'hooks/**/*.{ts,tsx}',
|
||||
@@ -16,29 +36,46 @@ export default tseslint.config(
|
||||
'components/**/*.{ts,tsx}',
|
||||
'services/**/*.{ts,tsx}',
|
||||
],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
// 💡 修复点 1(史诗级治愈):废除脆弱的 project 硬编码路径!
|
||||
// 拥抱 typescript-eslint 官方推荐的 projectService 常驻动态类型调度中枢。
|
||||
// 它会在内存中全自动、流式为所有新建、悬空或暂存文件分配编译上下文,
|
||||
// 彻底终结 "file is not included in any tsconfig" 的全量崩溃黑洞!
|
||||
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,
|
||||
},
|
||||
|
||||
// 💡 修复点 2:高精对齐 React 19 / JSX Runtime 的全量生产质检规则大闸
|
||||
rules: {
|
||||
// 激活 react-hooks 官方推荐规则
|
||||
...reactHooks.configs.recommended.rules,
|
||||
// 激活 react 官方精选规则(排除旧版 React 必须手动 import 的历史包袱)
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactPlugin.configs['jsx-runtime'].rules,
|
||||
|
||||
// 清洗原生未消费变量冲突,统一交由 TS 高阶哨兵接管
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
|
||||
// 彻底关闭老旧的 JSX 作用域检查,全面契合 React 19 核心美学
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import type { CustomDetector } from 'i18next-browser-languagedetector'; // 💡 1. 引入官方强类型探测器接口
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { storageUtil } from '@/utils/chromeStorage';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 导入 Day.js 本地化语言包
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
// 同步加载全局核心命名空间
|
||||
import commonZh from './locales/zh/common.json';
|
||||
import featuresZh from './locales/zh/features.json';
|
||||
import commonEn from './locales/en/common.json';
|
||||
import featuresEn from './locales/en/features.json';
|
||||
|
||||
const resources = {
|
||||
zh: {
|
||||
common: commonZh,
|
||||
features: featuresZh,
|
||||
},
|
||||
en: {
|
||||
common: commonEn,
|
||||
features: featuresEn,
|
||||
},
|
||||
};
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ['zh', 'en'] as const;
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'app/language';
|
||||
const LANGUAGE_SNAPSHOT_KEY = 'snapshot/app/language';
|
||||
|
||||
/**
|
||||
* 将任意语言标识归一化为受支持的核心代码
|
||||
*/
|
||||
export const normalizeLanguage = (lng: string): SupportedLanguage => {
|
||||
if (!lng) return 'en';
|
||||
return lng.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
||||
};
|
||||
|
||||
/**
|
||||
* 严格校验语言安全边界
|
||||
*/
|
||||
const isValidLanguage = (lng: unknown): lng is SupportedLanguage => {
|
||||
return typeof lng === 'string' && (SUPPORTED_LANGUAGES as readonly string[]).includes(lng);
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步从 localStorage 获取语言快照(消除异步闪烁)
|
||||
*/
|
||||
const getSyncLanguageSnapshot = (): SupportedLanguage | null => {
|
||||
try {
|
||||
const val = localStorage.getItem(LANGUAGE_SNAPSHOT_KEY);
|
||||
if (!val) return null;
|
||||
const parsed = JSON.parse(val) as unknown;
|
||||
return isValidLanguage(parsed) ? parsed : null;
|
||||
} catch (error) {
|
||||
console.error('[i18n] Failed to parse sync language snapshot from localStorage:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 💡 2. 强类型接口重塑:显式绑定 CustomDetector 类型,
|
||||
// 告诉 TS 编译器这些方法将被全局 Languagedetector 框架隐式调用,彻底治愈“未使用函数”报错!
|
||||
const chromeStorageDetector: CustomDetector = {
|
||||
name: 'chromeStorage',
|
||||
lookup() {
|
||||
return undefined;
|
||||
},
|
||||
cacheUserLanguage(lng: string) {
|
||||
const target = normalizeLanguage(lng);
|
||||
// 💡 修复点:对异步写盘操作追加 void 算子或 catch,吞掉 Promise 被忽略警告
|
||||
storageUtil.set(LANGUAGE_STORAGE_KEY, target).catch((err) => {
|
||||
console.error('[i18n Detector Error] Failed to write back language state:', err);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const detector = new LanguageDetector();
|
||||
detector.addDetector(chromeStorageDetector);
|
||||
|
||||
const syncLng = getSyncLanguageSnapshot();
|
||||
|
||||
// 💡 3. 修复点:对 i18n.init() 返回的异步 Promise 前方追加 void 斩断依赖链,放行编译
|
||||
void i18n
|
||||
.use(detector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
lng: syncLng || undefined,
|
||||
ns: ['common', 'features'],
|
||||
defaultNS: 'common',
|
||||
debug: false,
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['chromeStorage', 'navigator'],
|
||||
caches: ['chromeStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
// 监听语言变更
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
const normalizedLng = normalizeLanguage(lng);
|
||||
dayjs.locale(normalizedLng === 'zh' ? 'zh-cn' : 'en');
|
||||
localStorage.setItem(LANGUAGE_SNAPSHOT_KEY, JSON.stringify(normalizedLng));
|
||||
});
|
||||
|
||||
// 初始化时从长期异步存储中恢复校准
|
||||
storageUtil
|
||||
.get(LANGUAGE_STORAGE_KEY)
|
||||
.then((lng) => {
|
||||
const rawTargetLng = lng || syncLng;
|
||||
|
||||
if (!rawTargetLng) {
|
||||
const initialLng = normalizeLanguage(i18n.language);
|
||||
|
||||
// 💡 修复点:对初始化同步写盘追加安全的 Promise .catch() 异常隔离防护罩
|
||||
storageUtil.set(LANGUAGE_STORAGE_KEY, initialLng).catch((err) => {
|
||||
console.error('[i18n Init Error] Persistent sync collapsed:', err);
|
||||
});
|
||||
|
||||
if (initialLng !== normalizeLanguage(i18n.language)) {
|
||||
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
|
||||
void i18n.changeLanguage(initialLng);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLng = normalizeLanguage(String(rawTargetLng));
|
||||
|
||||
if (isValidLanguage(targetLng) && targetLng !== normalizeLanguage(i18n.language)) {
|
||||
// 💡 修复点:对 changeLanguage 异步微任务进行显式 void 断链安全隔离
|
||||
void i18n.changeLanguage(targetLng);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[i18n Context Error] Async local storage lookup collapsed:', err);
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"pageTitle": "Base64 Converter",
|
||||
"pageSubtitle": "Encode and decode text, files, and images with Base64",
|
||||
"textMode": "Text",
|
||||
"fileMode": "File",
|
||||
"imageMode": "Image",
|
||||
"encode": "Encode",
|
||||
"decode": "Decode",
|
||||
"clear": "Clear",
|
||||
"textInputPlaceholder": "Enter text to encode to Base64...",
|
||||
"base64InputPlaceholder": "Enter Base64 string to decode...",
|
||||
"base64Output": "Base64 Output",
|
||||
"textOutput": "Decoded Text Output",
|
||||
"copyRaw": "Copy Raw Base64",
|
||||
"copyDataUri": "Copy Data URI",
|
||||
"clickOrDropToFile": "Click or drop a file here",
|
||||
"clickOrDropToImage": "Click or drop an image here",
|
||||
"clickOrDropToReplace": "Click or drop to replace the file",
|
||||
"maxFileSize": "Maximum file size: {{max}}",
|
||||
"supportedFormats": "Supports PNG, JPG, WEBP, GIF, BMP, SVG, etc.",
|
||||
"fileSizeExceeded": "File size exceeds the limit (max {{max}})",
|
||||
"unsupportedImageType": "Unsupported image format",
|
||||
"conversionFailed": "Conversion failed",
|
||||
"originalSize": "Original Size",
|
||||
"encodedSize": "Encoded Size",
|
||||
"invalidBase64": "Invalid Base64 string",
|
||||
"binaryDataDetected": "Input appears to be binary data (e.g. an image). Please switch to the Image tab.",
|
||||
"imageDataUriHint": "Detected an image data URI — please use the Image tab to decode it.",
|
||||
"switchToImageMode": "Switch to Image mode",
|
||||
"download": "Download",
|
||||
"decodedFileName": "Decoded file name",
|
||||
"decodeBase64Placeholder": "Enter Base64 or data URI to decode...",
|
||||
"decodedFileOutput": "Decoded File",
|
||||
"decodedImageOutput": "Decoded Image",
|
||||
"inferredMimeType": "Inferred MIME type",
|
||||
"decodedSize": "Decoded size"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"appName": "Testing Tools",
|
||||
"buttons": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"copy": "Copy",
|
||||
"clear": "Clear",
|
||||
"refresh": "Refresh",
|
||||
"toggleLanguage": "Switch Language",
|
||||
"toggleTheme": "Toggle theme",
|
||||
"themeMode": {
|
||||
"light": "Switch to dark mode",
|
||||
"dark": "Switch to system mode",
|
||||
"system": "Switch to light mode"
|
||||
},
|
||||
"search": "Search tools...",
|
||||
"back": "Back",
|
||||
"clearSearch": "Clear search",
|
||||
"recentSearch": "Recent search",
|
||||
"noResults": "No tools found",
|
||||
"openInTab": "Open in tab",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"messages": {
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyError": "Copy failed"
|
||||
},
|
||||
"textInputArea": {
|
||||
"clear": "Clear",
|
||||
"copyContent": "Copy content",
|
||||
"cleared": "Cleared",
|
||||
"placeholder": "placeholder text"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Dashboard"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "Timestamp",
|
||||
"description": "Unix millisecond conversion and formatting"
|
||||
},
|
||||
"storageCleaner": {
|
||||
"title": "Storage Cleaner",
|
||||
"description": "Clean cache, cookies, and local storage"
|
||||
},
|
||||
"qrCode": {
|
||||
"title": "QR Code Tools",
|
||||
"description": "Generate QR code for the selected URL"
|
||||
},
|
||||
"textStatistics": {
|
||||
"title": "Text Statistics",
|
||||
"description": "Real-time analysis of text characters, words, and bytes"
|
||||
},
|
||||
"jwt": {
|
||||
"title": "JWT Parser",
|
||||
"description": "JSON Web Token decoding and viewing"
|
||||
},
|
||||
"jsonDiff": {
|
||||
"title": "JSON Tools",
|
||||
"description": "Diff, format, YAML/TOML conversion, and minify"
|
||||
},
|
||||
"base64Converter": {
|
||||
"title": "Base64 Converter",
|
||||
"description": "Encode text, files, and images to Base64"
|
||||
},
|
||||
"markdownToHtml": {
|
||||
"title": "Markdown to HTML",
|
||||
"description": "Real-time Markdown conversion and HTML preview"
|
||||
},
|
||||
"htmlToMarkdown": {
|
||||
"title": "HTML to Markdown",
|
||||
"description": "Real-time HTML conversion and Markdown preview"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pageTitle": "HTML to Markdown",
|
||||
"pageSubtitle": "Convert HTML to Markdown in real-time",
|
||||
"splitMode": "Split",
|
||||
"previewMode": "Preview",
|
||||
"markdownMode": "Markdown",
|
||||
"clear": "Clear",
|
||||
"inputLabel": "HTML Input",
|
||||
"previewLabel": "Markdown Preview",
|
||||
"markdownOutputLabel": "Markdown Output",
|
||||
"inputPlaceholder": "Enter your HTML content here...",
|
||||
"charCount": "{{count}} chars",
|
||||
"copyMarkdown": "Copy Markdown",
|
||||
"download": "Download",
|
||||
"emptyHint": "Enter HTML content to see the conversion result"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"pageTitle": "JSON Diff",
|
||||
"pageSubtitle": "Compare differences between two JSON values",
|
||||
"leftPlaceholder": "Enter the original JSON...",
|
||||
"rightPlaceholder": "Enter the target JSON...",
|
||||
"leftLabel": "Original JSON",
|
||||
"rightLabel": "Target JSON",
|
||||
"compareButton": "Compare",
|
||||
"clearButton": "Clear",
|
||||
"sideBySideMode": "Side by side",
|
||||
"unifiedMode": "Unified",
|
||||
"previousDiff": "Previous",
|
||||
"nextDiff": "Next",
|
||||
"noDiffs": "No differences",
|
||||
"diffCount": "{{count}} differences",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"emptyHint": "Enter JSON on both sides and click Compare",
|
||||
"added": "Added",
|
||||
"removed": "Removed",
|
||||
"modified": "Modified"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"formatTitle": "JSON Formatter",
|
||||
"formatSubtitle": "Beautify and format JSON data",
|
||||
"inputPlaceholder": "Enter JSON to format...",
|
||||
"formatButton": "Format",
|
||||
"clearButton": "Clear",
|
||||
"sortKeys": "Sort Keys",
|
||||
"sortKeysTooltip": "Sort JSON object keys in alphabetical order",
|
||||
"indentSize": "Indent",
|
||||
"indentSpaces": "{{count}} spaces",
|
||||
"outputLabel": "Formatted Result",
|
||||
"copySuccess": "Copied",
|
||||
"copyFail": "Copy failed",
|
||||
"noContent": "Nothing to copy",
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"emptyHint": "Enter JSON and click Format",
|
||||
"originalSize": "Original size",
|
||||
"formattedSize": "Formatted size",
|
||||
"diffMode": "Diff",
|
||||
"formatMode": "Format",
|
||||
"yamlMode": "YAML",
|
||||
"tomlMode": "TOML",
|
||||
"minifyMode": "Minify",
|
||||
"yamlTitle": "JSON to YAML",
|
||||
"yamlSubtitle": "Convert JSON data to YAML format",
|
||||
"yamlModeInputPlaceholder": "Enter JSON to convert...",
|
||||
"yamlModeOutputLabel": "YAML Result",
|
||||
"yamlModeEmptyHint": "Enter JSON and click Convert",
|
||||
"convertButton": "Convert",
|
||||
"tomlTitle": "JSON to TOML",
|
||||
"tomlSubtitle": "Convert JSON data to TOML format",
|
||||
"tomlModeInputPlaceholder": "Enter JSON to convert...",
|
||||
"tomlModeOutputLabel": "TOML Result",
|
||||
"tomlModeEmptyHint": "Enter JSON and click Convert",
|
||||
"minifyTitle": "JSON Minifier",
|
||||
"minifySubtitle": "Compress JSON into a compact single-line format",
|
||||
"minifyModeInputPlaceholder": "Enter JSON to minify...",
|
||||
"minifyModeOutputLabel": "Minified Result",
|
||||
"minifyModeEmptyHint": "Enter JSON and click Minify",
|
||||
"minifyButton": "Minify"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"pageTitle": "JWT Parser",
|
||||
"pageSubtitle": "JSON Web Token decoding and viewing",
|
||||
"placeholder": "Paste Encoded JWT here...",
|
||||
"headerTitle": "HEADER: Algorithm & Token Type",
|
||||
"payloadTitle": "PAYLOAD: Data",
|
||||
"signatureTitle": "Signature",
|
||||
"noSignature": "No Signature",
|
||||
"invalidFormat": "Unable to parse",
|
||||
"errors": {
|
||||
"invalidBase64String": "Invalid base64url string",
|
||||
"failedToDecode": "Failed to decode base64url: ",
|
||||
"invalidFormat": "Invalid JWT format: expected 3 parts separated by .",
|
||||
"parseHeaderFailed": "Failed to parse Header: ",
|
||||
"parsePayloadFailed": "Failed to parse Payload: "
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"pageTitle": "Markdown to HTML",
|
||||
"pageSubtitle": "Convert Markdown to HTML in real-time with preview",
|
||||
"splitMode": "Split",
|
||||
"previewMode": "Preview",
|
||||
"htmlMode": "HTML",
|
||||
"clear": "Clear",
|
||||
"print": "Print",
|
||||
"download": "Download",
|
||||
"inputLabel": "Markdown Input",
|
||||
"previewLabel": "Live Preview",
|
||||
"htmlOutputLabel": "HTML Output",
|
||||
"inputPlaceholder": "Enter your Markdown content here...",
|
||||
"charCount": "{{count}} chars",
|
||||
"copyHtml": "Copy HTML",
|
||||
"downloadSuccess": "File downloaded successfully",
|
||||
"printSuccess": "Print window opened"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"pageTitle": "QR Code Tools",
|
||||
"pageSubtitle": "Generate and parse QR codes",
|
||||
|
||||
"generateMode": "Generate QR Code",
|
||||
"parseMode": "Parse QR Code",
|
||||
|
||||
"urlToQr": "Text to QR Code",
|
||||
"qrToUrl": "QR Code to Text",
|
||||
|
||||
"urlInputLabel": "Enter URL or Text",
|
||||
"urlInputPlaceholder": "Enter URL or text content, QR code will be generated automatically",
|
||||
"generateButton": "Generate QR Code",
|
||||
"generating": "Generating...",
|
||||
|
||||
"qrCodeWillShow": "QR code will be shown here",
|
||||
"downloadButton": "Download QR Code",
|
||||
"copyQrButton": "Copy QR Code Image",
|
||||
|
||||
"qrCodeSuccess": "QR code generated successfully",
|
||||
"qrCodeDownloadSuccess": "QR code downloaded successfully",
|
||||
"qrCodeCopySuccess": "QR code copied to clipboard",
|
||||
|
||||
"selectImage": "Please select a QR code image",
|
||||
"parseSuccess": "QR code parsed successfully",
|
||||
"noQrDetected": "No QR code detected, please ensure the image is clear and contains a QR code",
|
||||
"parseError": "Failed to parse QR code, please try again",
|
||||
"generateError": "Failed to generate QR code, please check the input",
|
||||
"copyError": "Copy failed, please try again",
|
||||
|
||||
"imagePasted": "Image pasted, parsing...",
|
||||
"imagePasteError": "Failed to paste image, please try again",
|
||||
"imageCleared": "Image cleared",
|
||||
"clickToUpload": "Click, drag, or paste to upload QR code image",
|
||||
"supportFormats": "Supports PNG, JPG, WEBP, Base64 formats",
|
||||
|
||||
"parseButton": "Parse QR Code",
|
||||
"parsing": "Parsing...",
|
||||
"resultLabel": "Parsing Result",
|
||||
"copyTooltip": "Copy",
|
||||
|
||||
"enterUrlError": "Please enter URL or text",
|
||||
"clickToChange": "Click to change image",
|
||||
|
||||
"pasteHint": "Supports Ctrl+V to paste images or Base64 strings",
|
||||
"autoGenerateHint": "QR code will be generated automatically after input",
|
||||
"autoParseHint": "QR code will be parsed automatically after upload"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"pageTitle": "Storage Cleaner",
|
||||
"pageSubtitle": "Clear cache, cookies, and local storage",
|
||||
"loading": "Loading...",
|
||||
"occupied": "Occupied {{size}}",
|
||||
"cleaning": "Cleaning...",
|
||||
"cleanNow": "Clean Now",
|
||||
"autoRefresh": "Auto refresh page after cleaning",
|
||||
"selectAll": "Select all items",
|
||||
"cleanSuccess": "Cleaning complete",
|
||||
"cleanError": "Cleaning failed",
|
||||
"noData": "No data",
|
||||
"countUnit": "items",
|
||||
"errorNoTab": "Unable to get current tab",
|
||||
"errorRestricted": "Storage cleaning is not supported on this page",
|
||||
"cleanSuccessReload": "Cleaning successful, reloading page...",
|
||||
"errorStandardOnly": "Storage cleaning only works on standard web pages",
|
||||
"confirmTitle": "Confirm Clear Data?",
|
||||
"confirmDesc": "You are about to permanently delete the following selected storage items from the current page.",
|
||||
"irreversible": "This action is irreversible",
|
||||
"confirmAction": "Confirm Clear",
|
||||
"cleanedSummary": "Cleaned {{items}}",
|
||||
"noDataToClean": "No storage data found to clean",
|
||||
"partialFailure": "Some items failed to clean",
|
||||
"options": {
|
||||
"localStorage": "Local Storage",
|
||||
"sessionStorage": "Session Storage",
|
||||
"indexedDB": "IndexedDB",
|
||||
"cookies": "Cookies",
|
||||
"cacheStorage": "Cache Storage",
|
||||
"serviceWorkers": "Service Workers"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"pageTitle": "Text Statistics",
|
||||
"pageSubtitle": "Real-time analysis of characters, words, lines, and byte size",
|
||||
"placeholder": "Type or paste text here...",
|
||||
"characters": "Characters",
|
||||
"words": "Words",
|
||||
"lines": "Lines",
|
||||
"bytes": "Byte Size"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"pageTitle": "Timestamp Conversion",
|
||||
"pageSubtitle": "Unix millisecond conversion and formatting",
|
||||
"tsToDate": "Timestamp → Date",
|
||||
"dateToTs": "Date → Timestamp",
|
||||
"placeholderTs": "Enter timestamp...",
|
||||
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
|
||||
"unitMs": "Millisecond (ms)",
|
||||
"unitS": "Second (s)",
|
||||
"convertButton": "Convert Now",
|
||||
"currentTs": "Current Timestamp",
|
||||
"useNowTooltip": "Use this value",
|
||||
"copyTsTooltip": "Copy timestamp",
|
||||
"usedSuccess": "Using current timestamp",
|
||||
"resultLabel": "Conversion Result",
|
||||
"copyResultTooltip": "Copy result",
|
||||
"relativeTime": "Relative Time",
|
||||
"iso8601": "ISO 8601",
|
||||
"utcTime": "UTC Time",
|
||||
"copyTooltip": "Copy",
|
||||
"resultEmpty": "Enter a value and click convert",
|
||||
"errors": {
|
||||
"invalidNumber": "Invalid number",
|
||||
"invalidTimestamp": "Invalid timestamp",
|
||||
"invalidFormat": "Format error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"pageTitle": "Base64 转换器",
|
||||
"pageSubtitle": "文本、文件与图像的 Base64 编码与解码",
|
||||
"textMode": "文本",
|
||||
"fileMode": "文件",
|
||||
"imageMode": "图像",
|
||||
"encode": "编码",
|
||||
"decode": "解码",
|
||||
"clear": "清空",
|
||||
"textInputPlaceholder": "输入需要编码为 Base64 的文本...",
|
||||
"base64InputPlaceholder": "输入需要解码的 Base64 字符串...",
|
||||
"base64Output": "Base64 编码结果",
|
||||
"textOutput": "解码文本结果",
|
||||
"copyRaw": "复制纯 Base64",
|
||||
"copyDataUri": "复制 Data URI",
|
||||
"clickOrDropToFile": "点击或拖拽文件到此处",
|
||||
"clickOrDropToImage": "点击或拖拽图像到此处",
|
||||
"clickOrDropToReplace": "点击或拖拽以替换文件",
|
||||
"maxFileSize": "最大文件大小:{{max}}",
|
||||
"supportedFormats": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
|
||||
"fileSizeExceeded": "文件大小超出限制(最大 {{max}})",
|
||||
"unsupportedImageType": "不支持的图像格式",
|
||||
"conversionFailed": "转换失败",
|
||||
"originalSize": "原始大小",
|
||||
"encodedSize": "编码大小",
|
||||
"invalidBase64": "Base64 字符串无效",
|
||||
"binaryDataDetected": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
|
||||
"imageDataUriHint": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
|
||||
"switchToImageMode": "切换到图像模式",
|
||||
"download": "下载",
|
||||
"decodedFileName": "解码后文件名",
|
||||
"decodeBase64Placeholder": "输入需要解码的 Base64 或 data URI...",
|
||||
"decodedFileOutput": "解码文件",
|
||||
"decodedImageOutput": "解码图像",
|
||||
"inferredMimeType": "推断的 MIME 类型",
|
||||
"decodedSize": "解码大小"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"appName": "测试工具",
|
||||
"buttons": {
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"copy": "复制",
|
||||
"clear": "清理",
|
||||
"refresh": "刷新",
|
||||
"toggleLanguage": "切换语言",
|
||||
"toggleTheme": "切换主题",
|
||||
"themeMode": {
|
||||
"light": "切换到深色模式",
|
||||
"dark": "切换到系统模式",
|
||||
"system": "切换到浅色模式"
|
||||
},
|
||||
"search": "搜索工具...",
|
||||
"back": "返回",
|
||||
"clearSearch": "清除搜索",
|
||||
"recentSearch": "最近搜索",
|
||||
"noResults": "未找到相关工具",
|
||||
"openInTab": "在标签页打开",
|
||||
"settings": "设置"
|
||||
},
|
||||
"messages": {
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyError": "复制失败"
|
||||
},
|
||||
"textInputArea": {
|
||||
"clear": "清空",
|
||||
"copyContent": "复制内容",
|
||||
"cleared": "已清空",
|
||||
"placeholder": "请输入文本"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "仪表盘"
|
||||
},
|
||||
"timestamp": {
|
||||
"title": "时间戳",
|
||||
"description": "Unix 毫秒数转换与格式化"
|
||||
},
|
||||
"storageCleaner": {
|
||||
"title": "存储清理",
|
||||
"description": "清理缓存、Cookies 及本地存储"
|
||||
},
|
||||
"qrCode": {
|
||||
"title": "二维码工具",
|
||||
"description": "生成当前选中的 URL 的二维码"
|
||||
},
|
||||
"textStatistics": {
|
||||
"title": "文本统计",
|
||||
"description": "实时分析文本字符、单词及字节"
|
||||
},
|
||||
"jwt": {
|
||||
"title": "JWT 解析",
|
||||
"description": "JSON Web Token 解码与查看"
|
||||
},
|
||||
"jsonDiff": {
|
||||
"title": "JSON 工具",
|
||||
"description": "差异比较、格式化、YAML/TOML 转换及压缩"
|
||||
},
|
||||
"base64Converter": {
|
||||
"title": "Base64 转换器",
|
||||
"description": "文本、文件与图像的 Base64 编码转换"
|
||||
},
|
||||
"markdownToHtml": {
|
||||
"title": "Markdown 转 HTML",
|
||||
"description": "实时 Markdown 转换与 HTML 预览"
|
||||
},
|
||||
"htmlToMarkdown": {
|
||||
"title": "HTML 转 Markdown",
|
||||
"description": "实时 HTML 转换与 Markdown 预览"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"pageTitle": "HTML 转 Markdown",
|
||||
"pageSubtitle": "实时将 HTML 转换为 Markdown 格式",
|
||||
"splitMode": "分屏",
|
||||
"previewMode": "预览",
|
||||
"markdownMode": "Markdown",
|
||||
"clear": "清空",
|
||||
"inputLabel": "HTML 输入",
|
||||
"previewLabel": "Markdown 预览",
|
||||
"markdownOutputLabel": "Markdown 输出",
|
||||
"inputPlaceholder": "在此输入 HTML 内容...",
|
||||
"charCount": "{{count}} 字符",
|
||||
"copyMarkdown": "复制 Markdown",
|
||||
"download": "下载",
|
||||
"emptyHint": "输入 HTML 内容以查看转换结果"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"pageTitle": "JSON 差异比较",
|
||||
"pageSubtitle": "对比两个 JSON 数据的差异",
|
||||
"leftPlaceholder": "输入原始 JSON...",
|
||||
"rightPlaceholder": "输入目标 JSON...",
|
||||
"leftLabel": "原始 JSON",
|
||||
"rightLabel": "目标 JSON",
|
||||
"compareButton": "比较",
|
||||
"clearButton": "清空",
|
||||
"sideBySideMode": "并排",
|
||||
"unifiedMode": "统一",
|
||||
"previousDiff": "上一个",
|
||||
"nextDiff": "下一个",
|
||||
"noDiffs": "无差异",
|
||||
"diffCount": "{{count}} 处差异",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"emptyHint": "输入两侧 JSON 后点击比较",
|
||||
"added": "新增",
|
||||
"removed": "删除",
|
||||
"modified": "修改"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"formatTitle": "JSON 格式化",
|
||||
"formatSubtitle": "美化和格式化 JSON 数据",
|
||||
"inputPlaceholder": "输入需要格式化的 JSON...",
|
||||
"formatButton": "格式化",
|
||||
"clearButton": "清空",
|
||||
"sortKeys": "键名排序",
|
||||
"sortKeysTooltip": "按字母顺序对 JSON 对象键进行排序",
|
||||
"indentSize": "缩进",
|
||||
"indentSpaces": "{{count}} 个空格",
|
||||
"outputLabel": "格式化结果",
|
||||
"copySuccess": "复制成功",
|
||||
"copyFail": "复制失败",
|
||||
"noContent": "无内容可复制",
|
||||
"invalidJson": "无效的 JSON 格式",
|
||||
"emptyHint": "输入 JSON 后点击格式化",
|
||||
"originalSize": "原始大小",
|
||||
"formattedSize": "格式化后大小",
|
||||
"diffMode": "差异比较",
|
||||
"formatMode": "格式化",
|
||||
"yamlMode": "YAML",
|
||||
"tomlMode": "TOML",
|
||||
"minifyMode": "压缩",
|
||||
"yamlTitle": "JSON 转 YAML",
|
||||
"yamlSubtitle": "将 JSON 数据转换为 YAML 格式",
|
||||
"yamlModeInputPlaceholder": "输入需要转换的 JSON...",
|
||||
"yamlModeOutputLabel": "YAML 结果",
|
||||
"yamlModeEmptyHint": "输入 JSON 后点击转换",
|
||||
"convertButton": "转换",
|
||||
"tomlTitle": "JSON 转 TOML",
|
||||
"tomlSubtitle": "将 JSON 数据转换为 TOML 格式",
|
||||
"tomlModeInputPlaceholder": "输入需要转换的 JSON...",
|
||||
"tomlModeOutputLabel": "TOML 结果",
|
||||
"tomlModeEmptyHint": "输入 JSON 后点击转换",
|
||||
"minifyTitle": "JSON 压缩",
|
||||
"minifySubtitle": "将 JSON 压缩为紧凑的单行格式",
|
||||
"minifyModeInputPlaceholder": "输入需要压缩的 JSON...",
|
||||
"minifyModeOutputLabel": "压缩结果",
|
||||
"minifyModeEmptyHint": "输入 JSON 后点击压缩",
|
||||
"minifyButton": "压缩"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"pageTitle": "JWT 解析",
|
||||
"pageSubtitle": "JSON Web Token 解码与查看",
|
||||
"placeholder": "在此粘贴 JWT 令牌 (Encoded JWT)...",
|
||||
"headerTitle": "HEADER: 算法 & 令牌类型",
|
||||
"payloadTitle": "PAYLOAD: 数据",
|
||||
"signatureTitle": "签名",
|
||||
"noSignature": "无签名",
|
||||
"invalidFormat": "无法解析",
|
||||
"errors": {
|
||||
"invalidBase64String": "无效的 Base64URL 字符串",
|
||||
"failedToDecode": "Base64URL 解码失败:",
|
||||
"invalidFormat": "JWT 格式错误:必须包含三个由 . 分隔的部分",
|
||||
"parseHeaderFailed": "解析 Header 失败:",
|
||||
"parsePayloadFailed": "解析 Payload 失败:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"pageTitle": "Markdown 转 HTML",
|
||||
"pageSubtitle": "实时将 Markdown 转换为 HTML 并预览",
|
||||
"splitMode": "分屏",
|
||||
"previewMode": "预览",
|
||||
"htmlMode": "HTML",
|
||||
"clear": "清空",
|
||||
"print": "打印",
|
||||
"download": "下载",
|
||||
"inputLabel": "Markdown 输入",
|
||||
"previewLabel": "实时预览",
|
||||
"htmlOutputLabel": "HTML 输出",
|
||||
"inputPlaceholder": "在此输入 Markdown 内容...",
|
||||
"charCount": "{{count}} 字符",
|
||||
"copyHtml": "复制 HTML",
|
||||
"downloadSuccess": "文件下载成功",
|
||||
"printSuccess": "打印窗口已打开"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"pageTitle": "二维码工具",
|
||||
"pageSubtitle": "生成和解析二维码",
|
||||
|
||||
"generateMode": "生成二维码",
|
||||
"parseMode": "解析二维码",
|
||||
|
||||
"urlToQr": "文本转二维码",
|
||||
"qrToUrl": "二维码转文本",
|
||||
|
||||
"urlInputLabel": "输入 URL 或文本",
|
||||
"urlInputPlaceholder": "请输入 URL 或文本内容,将自动生成二维码",
|
||||
"generateButton": "生成二维码",
|
||||
"generating": "生成中...",
|
||||
|
||||
"qrCodeWillShow": "二维码将显示在这里",
|
||||
"downloadButton": "下载二维码",
|
||||
"copyQrButton": "复制二维码",
|
||||
|
||||
"qrCodeSuccess": "二维码生成成功",
|
||||
"qrCodeDownloadSuccess": "二维码下载成功",
|
||||
"qrCodeCopySuccess": "二维码已复制到剪贴板",
|
||||
|
||||
"selectImage": "请选择二维码图片",
|
||||
"parseSuccess": "二维码解析成功",
|
||||
"noQrDetected": "未检测到二维码,请确保图片清晰且包含二维码",
|
||||
"parseError": "解析二维码失败,请重试",
|
||||
"generateError": "生成二维码失败,请检查输入内容",
|
||||
"copyError": "复制失败,请重试",
|
||||
|
||||
"imagePasted": "图片粘贴成功,正在解析...",
|
||||
"imagePasteError": "粘贴图片失败,请重试",
|
||||
"imageCleared": "图片已清除",
|
||||
"clickToUpload": "点击、拖拽或粘贴上传二维码图片",
|
||||
"supportFormats": "支持 PNG、JPG、WEBP、Base64 格式",
|
||||
|
||||
"parseButton": "解析二维码",
|
||||
"parsing": "解析中...",
|
||||
"resultLabel": "解析结果",
|
||||
"copyTooltip": "复制",
|
||||
|
||||
"enterUrlError": "请输入 URL 或文本",
|
||||
"clickToChange": "点击更换图片",
|
||||
|
||||
"pasteHint": "支持 Ctrl+V 粘贴图片或 Base64 字符串",
|
||||
"autoGenerateHint": "输入内容后将自动生成二维码",
|
||||
"autoParseHint": "上传图片后将自动解析二维码"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"pageTitle": "存储清理",
|
||||
"pageSubtitle": "清理缓存、Cookies 及本地存储",
|
||||
"loading": "加载中...",
|
||||
"occupied": "已占用 {{size}}",
|
||||
"cleaning": "正在清理...",
|
||||
"cleanNow": "立即清理",
|
||||
"autoRefresh": "清理后自动刷新页面",
|
||||
"selectAll": "全选所有项",
|
||||
"cleanSuccess": "清理完成",
|
||||
"cleanError": "清理失败",
|
||||
"noData": "无数据",
|
||||
"countUnit": "个",
|
||||
"errorNoTab": "无法获取当前标签页",
|
||||
"errorRestricted": "存储清理功能不支持此页面",
|
||||
"cleanSuccessReload": "清理成功,即将刷新页面",
|
||||
"errorStandardOnly": "存储清理功能仅适用于标准网页",
|
||||
"confirmTitle": "确认清理数据?",
|
||||
"confirmDesc": "您将永久删除当前页面的以下选定存储项。",
|
||||
"irreversible": "此操作不可撤销",
|
||||
"confirmAction": "确认清理",
|
||||
"cleanedSummary": "清理了 {{items}}",
|
||||
"noDataToClean": "该页面没有可清理的存储数据",
|
||||
"partialFailure": "部分清理失败",
|
||||
"options": {
|
||||
"localStorage": "Local Storage",
|
||||
"sessionStorage": "Session Storage",
|
||||
"indexedDB": "IndexedDB",
|
||||
"cookies": "Cookies",
|
||||
"cacheStorage": "Cache Storage",
|
||||
"serviceWorkers": "Service Workers"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"pageTitle": "文本统计",
|
||||
"pageSubtitle": "实时分析文本的字符、单词、行数及字节大小",
|
||||
"placeholder": "在此输入或粘贴文本...",
|
||||
"characters": "字符数",
|
||||
"words": "单词数",
|
||||
"lines": "行数",
|
||||
"bytes": "字节大小"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"pageTitle": "时间戳转换",
|
||||
"pageSubtitle": "Unix 毫秒数转换与格式化",
|
||||
"tsToDate": "时间戳 → 日期",
|
||||
"dateToTs": "日期 → 时间戳",
|
||||
"placeholderTs": "输入时间戳...",
|
||||
"placeholderDate": "YYYY-MM-DD HH:mm:ss",
|
||||
"unitMs": "毫秒 (ms)",
|
||||
"unitS": "秒 (s)",
|
||||
"convertButton": "立即转换",
|
||||
"currentTs": "当前时间戳",
|
||||
"useNowTooltip": "填充到下方",
|
||||
"copyTsTooltip": "复制时间戳",
|
||||
"usedSuccess": "已使用当前时间戳",
|
||||
"resultLabel": "转换结果",
|
||||
"copyResultTooltip": "复制结果",
|
||||
"relativeTime": "相对时间",
|
||||
"iso8601": "ISO 8601",
|
||||
"utcTime": "UTC 时间",
|
||||
"copyTooltip": "复制",
|
||||
"resultEmpty": "请输入并点击转换",
|
||||
"errors": {
|
||||
"invalidNumber": "无效数字",
|
||||
"invalidTimestamp": "无效时间戳",
|
||||
"invalidFormat": "格式错误"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
+1
-12
@@ -1,15 +1,4 @@
|
||||
export default {
|
||||
// 对于代码文件:
|
||||
'*.{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 --max-warnings=0 --no-warn-ignored', 'prettier --write'],
|
||||
'*.{json,css,scss,md}': ['prettier --write'],
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user