Compare commits
224 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 400a1afb0c | |||
| c6d0914d33 | |||
| 7617e317f2 | |||
| 396b3d7013 | |||
| 92fb853329 | |||
| e4e5464be0 | |||
| f4b6ae89b5 | |||
| 37bdeb5f3a | |||
| e919af6857 | |||
| 5be15301b5 | |||
| 306600a35c | |||
| a0345b384d | |||
| 9458d62a99 | |||
| 5b26287cff | |||
| 6dca673a5a | |||
| afd57bb5b2 | |||
| 3f447708b7 | |||
| f9821553b3 | |||
| 355ab0cf86 | |||
| 13113f0ea2 | |||
| 0d69695dbb | |||
| 093bf278da | |||
| f9fbc79612 | |||
| 1b9f6d044d | |||
| b41cfb2a02 | |||
| 93019f56cf | |||
| 2727e60025 | |||
| c2e54b34cb | |||
| dbdec710ef | |||
| 7bd52bfc2b | |||
| c9fe27a26b | |||
| e97f4cae9a | |||
| 21a6430f0d | |||
| dff2705ef8 | |||
| f8c48a3047 | |||
| 488e2e8e49 | |||
| 98abe59731 | |||
| 77168e2652 | |||
| 4f7e402dba | |||
| e33761381d | |||
| 945780def8 | |||
| 6bddc4fcfc | |||
| 3df45f491c | |||
| 0054e97a0a | |||
| 97159cb799 | |||
| 5175677b24 | |||
| 3400ffc9dc | |||
| 4955f617ee | |||
| 2cb5b0c1e7 | |||
| c6cadda95b | |||
| 067a20fb0a | |||
| ca4bfc33fd | |||
| e140af7698 | |||
| b79fe7dd49 | |||
| 9903483f42 | |||
| 08911d4cba | |||
| 1daf049737 | |||
| 8b6bac9f2c | |||
| aa23a263fe | |||
| d4c29a1bf4 | |||
| 64e23994a6 | |||
| 528621f16e | |||
| c735a0aa1a | |||
| 62c880b0a8 | |||
| e263b63ced | |||
| 1e4b6eddc7 | |||
| 4316bb57d2 | |||
| f40485e2dc | |||
| 0908358f97 | |||
| a3f89d07ea | |||
| 003dad5d8b | |||
| 27d3360f9d | |||
| 67629263f9 | |||
| 8b58dcf02e | |||
| 2e2f6e9835 | |||
| cdad9bda68 | |||
| fae8dea971 | |||
| e0de152645 | |||
| e005d4d95d | |||
| 601260797e | |||
| c61b599287 | |||
| 42d3fedbaa | |||
| 0f3b6c4cd8 | |||
| af2262d2c9 | |||
| e5ae802ab2 | |||
| dec43f89da | |||
| 61e741f198 | |||
| c98f767809 | |||
| 92d524d832 | |||
| 63f47fd2b1 | |||
| 6abdf4add4 | |||
| e95ca1297c | |||
| a0d6e76cad | |||
| cc6d414d10 | |||
| c8ad2a2283 | |||
| 5daa151b5e | |||
| e93b5c817f | |||
| d678f14549 | |||
| c95e028482 | |||
| 8ec36ac3b3 | |||
| 262c5e9255 | |||
| e4993cb0ce | |||
| 0d60961a82 | |||
| df3eee8749 | |||
| cf05df7022 | |||
| e0888c7fb3 | |||
| 8b2bca9eef | |||
| 6bac88fbc4 | |||
| 4c08e17b19 | |||
| fa0f1bdcad | |||
| 2d7bb8fc2c | |||
| a0e6871b16 | |||
| 3adfe95234 | |||
| efbed7cf53 | |||
| 364672918d | |||
| 5e23b29a25 | |||
| cc9162f601 | |||
| 67bafe53d5 | |||
| ba57c070f9 | |||
| 34030e5f6a | |||
| 8b11dcd0ce | |||
| 67c0c30a7a | |||
| a3b2bfb43a | |||
| 81a2e793ba | |||
| 756ece31d3 | |||
| 7efed8673a | |||
| 88bebfa1b8 | |||
| 272bcae904 | |||
| 539f9648b1 | |||
| 7e58b5688f | |||
| 89e33f6273 | |||
| 9601afdd49 | |||
| ae0b923e11 | |||
| 917fca5947 | |||
| d2e5e6b45d | |||
| 336b62256d | |||
| 7ddc761df4 | |||
| 67553afe9c | |||
| 02aef0c7b1 | |||
| 94158f016b | |||
| dd03d9391f | |||
| 21e44cb889 | |||
| b3dd7146ad | |||
| 2ced38769b | |||
| 8406b03dd6 | |||
| da667ccb13 | |||
| 1bfb7d4e6d | |||
| b3c2fbccf5 | |||
| b29e9417cc | |||
| cf0d19914c | |||
| 6b2ac33b41 | |||
| 02a611e49b | |||
| 72883b5d1d | |||
| c7beebf6e0 | |||
| de7da9f1db | |||
| 5b9a0f59d3 | |||
| 3e1753da23 | |||
| 6763cd7862 | |||
| e2dd5f5915 | |||
| 1bc3ee59a0 | |||
| 6711783975 | |||
| 427e668e7c | |||
| 37618eb71f | |||
| d2334c5e95 | |||
| a4533be2c1 | |||
| 83bf9dd342 | |||
| 8902bc0f10 | |||
| 4753a7b54d | |||
| 7f9bf2334c | |||
| 6bd05aa6f1 | |||
| bdb40343b1 | |||
| 9a9336aea8 | |||
| 3eb00d8a29 | |||
| bb27577278 | |||
| 141e206b62 | |||
| 689c3faf16 | |||
| f5e8e96830 | |||
| f1f0047928 | |||
| f76fc3c140 | |||
| 9ed84382ea | |||
| 4a382144d1 | |||
| a654e64fe0 | |||
| ed8720e63e | |||
| 179a3d31f4 | |||
| 7cd99876d7 | |||
| 6b0de96b18 | |||
| 1df53323d0 | |||
| a5c34e5428 | |||
| 72f899477c | |||
| 8e2ac5200d | |||
| 604fbb4d1f | |||
| 704ec7289d | |||
| e4fac241f1 | |||
| 4b339fc628 | |||
| 70580a9a63 | |||
| 99a0400db3 | |||
| df0b9440ca | |||
| c5b51a9b33 | |||
| bc560cc453 | |||
| 2870e01610 | |||
| 9c03c585a8 | |||
| 0427f16cc9 | |||
| e9a6fa3dd8 | |||
| 00fe3eddd9 | |||
| 8e920c2a94 | |||
| 387a95d6a6 | |||
| 71995037dd | |||
| 8e56ebff6e | |||
| 5868316c3a | |||
| 8ec584c85f | |||
| c1890960ea | |||
| 3b98c9438e | |||
| 471e17eedf | |||
| 3fb99ea49f | |||
| 3d375f78f3 | |||
| 373fc0496c | |||
| 7167a43763 | |||
| 0cf8bae9f1 | |||
| a6dcdddf66 | |||
| 3c9c9e740e | |||
| ff964e3608 | |||
| 9d1d8a362a | |||
| 2a0a5658b5 | |||
| 57ea4d9858 |
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(npx vitest:*)",
|
||||||
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git stash:*)",
|
||||||
|
"Bash(git reset:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(npx eslint:*)",
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(npm test:*)",
|
||||||
|
"Bash(pnpm typecheck *)",
|
||||||
|
"Bash(pnpm test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Copilot 指令
|
||||||
|
|
||||||
|
基于 WXT 框架的浏览器扩展项目(React 19 + TypeScript),为开发者和测试人员提供效率工具:时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等。
|
||||||
|
|
||||||
|
## 核心命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Chrome 开发模式(支持 HMR)
|
||||||
|
npm run dev:firefox # Firefox 开发模式
|
||||||
|
npm run build # Chrome 生产构建
|
||||||
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展(.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
|
npm run lint # ESLint 检查(--max-warnings=0)
|
||||||
|
npm run typecheck # TypeScript 类型检查(tsc --noEmit)
|
||||||
|
npm run test # 运行全部单元测试(vitest run)
|
||||||
|
npm run test:watch # Vitest 监视模式
|
||||||
|
npm run test:coverage # 带覆盖率的测试
|
||||||
|
```
|
||||||
|
|
||||||
|
运行单个测试文件:`npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
|
修改 `package.json` 后需运行 `npm install`(会自动触发 `postinstall` → `wxt prepare` 重新生成 `.wxt/` 类型声明)。
|
||||||
|
|
||||||
|
## CI 流水线(GitHub Actions)
|
||||||
|
|
||||||
|
严格顺序门控,任一步骤失败则终止:
|
||||||
|
|
||||||
|
1. **setup** — 安装依赖,缓存 `node_modules`
|
||||||
|
2. **lint**、**typecheck**、**test** — 三者并行运行,全部通过才继续
|
||||||
|
3. **build** — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过时执行)
|
||||||
|
|
||||||
|
Pre-commit 钩子(`.husky/pre-commit` → `lint-staged`):
|
||||||
|
|
||||||
|
1. 代码文件(`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0`
|
||||||
|
2. 同一代码文件:运行 `prettier --write`
|
||||||
|
3. 其他文件(`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||||
|
|
||||||
|
## 项目架构
|
||||||
|
|
||||||
|
### 路由(不使用 React Router)
|
||||||
|
|
||||||
|
路由完全通过 `config/features.tsx` 中的 `FEATURES` 数组管理。每个功能定义一个 `key`(类型为 `types/storage.d.ts` 中的 `PageType`)和三个懒加载组件,分别对应 `popup`、`sidepanel`、`tab` 三种渲染模式。`providers/RouterProvider.tsx` 中的 `RouterProvider` 根据存储状态渲染当前页面。
|
||||||
|
|
||||||
|
存在三套独立的路由作用域:`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute`,各自维护独立的可见页面列表和页面排序。
|
||||||
|
|
||||||
|
### 存储
|
||||||
|
|
||||||
|
所有 Chrome Storage 键必须在 `types/storage.d.ts` 的 `StorageSchema` 中声明,键名使用 kebab-case 格式(如 `app/currentRoute`)。使用 `utils/chromeStorage.ts` 中的类型安全封装(`storageUtil.get/set/remove`)。
|
||||||
|
|
||||||
|
Router 同时使用 `chrome.storage.local` 持久化和 `localStorage` 快照来消除首屏闪烁。
|
||||||
|
|
||||||
|
### 扩展通信
|
||||||
|
|
||||||
|
使用 `@webext-core/messaging`。通信协议在 `utils/messages.ts` 中通过 `ProtocolMap` 定义。使用该模块导出的 `sendMessage` / `onMessage`,不要直接使用原生 `chrome.runtime.sendMessage`。
|
||||||
|
|
||||||
|
### 页面组件模式
|
||||||
|
|
||||||
|
功能页面遵循 **UI + Hook 分离** 模式,详见 [CODING_STANDARDS.md § 11](./CODING_STANDARDS.md#11-页面开发规范):
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/FeatureName/
|
||||||
|
├── index.tsx # UI 组件(纯展示,使用 shadcn/ui 组件)
|
||||||
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
|
└── constants.ts # 常量定义(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 页面组件调用 `useI18n('featureName')` 获取翻译函数
|
||||||
|
- Hook 负责所有状态管理,通过返回值暴露给页面
|
||||||
|
- 子组件可进一步拆分(如 `LiveClock.tsx`、`ResultView.tsx`)
|
||||||
|
|
||||||
|
### 新功能开发清单
|
||||||
|
|
||||||
|
1. 在 `types/storage.d.ts` 的 `PageType` 联合类型中添加新成员
|
||||||
|
2. 在 `config/features.tsx` 的 `FEATURES` 数组中添加配置(key、翻译键、图标、三种渲染模式组件)
|
||||||
|
3. 在 `pages/` 目录创建页面组件(懒加载):
|
||||||
|
- `index.tsx` — 使用 `useLazyTranslation` 的 UI 组件
|
||||||
|
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||||
|
- `constants.ts` — 常量(可选)
|
||||||
|
4. 在 `i18n/locales/{zh,en}/features.json` 添加翻译(复杂功能可新建独立 JSON 文件)
|
||||||
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
|
## 关键规范
|
||||||
|
|
||||||
|
> 完整的代码编写规范详见 [CODING_STANDARDS.md](./CODING_STANDARDS.md)。
|
||||||
|
|
||||||
|
### 浏览器 API
|
||||||
|
|
||||||
|
始终使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API,以确保跨浏览器兼容性。
|
||||||
|
|
||||||
|
### 路径别名
|
||||||
|
|
||||||
|
`@/` 映射到项目根目录(已在 tsconfig 和 vitest.config 中配置)。跨目录导入使用 `@/` 绝对别名,同目录导入使用 `./` 相对路径。
|
||||||
|
|
||||||
|
### UI 组件
|
||||||
|
|
||||||
|
- 使用 `components/ui/` 下的 shadcn/ui 组件(button、dialog、select、input 等)
|
||||||
|
- 图标:`lucide-react`
|
||||||
|
- 样式:Tailwind CSS + `@/lib/utils` 中的 `cn()` 工具函数(clsx + tailwind-merge)
|
||||||
|
- 主题:使用 shadcn/ui 语义化 token(`bg-background`、`text-foreground`、`border-border` 等),禁止硬编码颜色
|
||||||
|
|
||||||
|
### 代码分割
|
||||||
|
|
||||||
|
`wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组 vendor 依赖(vendor-react、vendor-i18n、vendor-qr、vendor-dnd),无需手动配置。
|
||||||
|
|
||||||
|
### 代码风格
|
||||||
|
|
||||||
|
- 禁止使用 `any`(测试文件除外)
|
||||||
|
- 未使用的变量/参数:使用 `_` 前缀(如 `_unused`)
|
||||||
|
- Prettier:100 字符宽、单引号、尾逗号 all、LF 换行
|
||||||
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`
|
||||||
|
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
|
||||||
|
- 环境:jsdom
|
||||||
|
- 全局变量:`vitest/globals`(describe、it、expect 等无需导入)
|
||||||
|
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
|
||||||
|
- `chrome.*` / `browser.*` API(storage、tabs、runtime、cookies 等)
|
||||||
|
- `react-i18next`(返回 key 作为翻译)
|
||||||
|
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
|
||||||
|
- `window.matchMedia`
|
||||||
|
- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
|
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
|
||||||
|
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
|
||||||
|
|
||||||
|
### 国际化(i18n)
|
||||||
|
|
||||||
|
- 命名空间:`common`(默认)、`features`
|
||||||
|
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||||
|
- 语言:`zh`(默认)、`en`
|
||||||
|
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
|
||||||
|
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
|
||||||
|
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
|
||||||
|
|
||||||
|
### WXT 生成文件
|
||||||
|
|
||||||
|
- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
|
||||||
|
- 生产构建输出到 `.output/` 目录
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
|
||||||
@@ -13,9 +13,11 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
setup:
|
||||||
name: Lint
|
name: Prepare Dependencies
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
cache-key: ${{ steps.cache-info.outputs.key }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -24,17 +26,50 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
- name: Cache Node Modules
|
||||||
|
id: cache-nodemodules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-node-v22-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Output Cache Key
|
||||||
|
id: cache-info
|
||||||
|
run: echo "key=${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Restore Node Modules Instantantly
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript Check
|
name: TypeScript Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -43,17 +78,23 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Restore Node Modules Instantantly
|
||||||
run: npm ci
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- name: Run TypeScript type check
|
- name: Run TypeScript type check
|
||||||
run: npm run compile
|
run: npm run typecheck
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -62,10 +103,15 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Restore Node Modules Instantantly
|
||||||
run: npm ci
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Generate WXT types
|
||||||
|
run: npx wxt prepare
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
@@ -73,10 +119,10 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build (${{ matrix.browser }})
|
name: Build (${{ matrix.browser }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, typecheck, test]
|
needs: [ lint, typecheck, test ]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
browser: [chrome]
|
browser: [ chrome, firefox ]
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -86,11 +132,20 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Restore Node Modules Instantantly
|
||||||
run: npm ci
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-node-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
- name: Build (Chrome)
|
- name: Generate WXT types
|
||||||
if: matrix.browser == 'chrome'
|
run: npx wxt prepare
|
||||||
run: npm run build
|
|
||||||
|
- name: Build Extension (${{ matrix.browser }})
|
||||||
|
run: |
|
||||||
|
if npm run | grep -q "build:${{ matrix.browser }}"; then
|
||||||
|
npm run build:${{ matrix.browser }}
|
||||||
|
else
|
||||||
|
npm run build -- --browser ${{ matrix.browser }}
|
||||||
|
fi
|
||||||
@@ -9,97 +9,142 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Phase 1: 全量 CI 检查 ────────────────────────────────────────────
|
# ── Phase 1: 依赖准备 ───────────────────
|
||||||
|
setup:
|
||||||
|
name: Prepare Dependencies
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
id: cache-nodemodules
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-release-v22-
|
||||||
|
- if: steps.cache-nodemodules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# ── Phase 2: 质量检查 ──────────────────
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
- name: Install dependencies
|
path: node_modules
|
||||||
run: npm ci
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npm run lint
|
||||||
- name: Run ESLint
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
name: TypeScript Check
|
name: TypeScript Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
- name: Install dependencies
|
path: node_modules
|
||||||
run: npm ci
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
- name: Run TypeScript type check
|
- run: npm run typecheck
|
||||||
run: npm run compile
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Unit Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: setup
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npx wxt prepare
|
||||||
|
- run: npm run test
|
||||||
|
|
||||||
- name: Install dependencies
|
# ── Phase 3: 打包 ───────────────────────
|
||||||
run: npm ci
|
build-extension:
|
||||||
|
name: Package Extension
|
||||||
- name: Run tests
|
|
||||||
run: npm run test
|
|
||||||
|
|
||||||
# ── Phase 2: 打包 & 发布 ─────────────────────────────────────────────
|
|
||||||
release:
|
|
||||||
name: Package & Release
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint, typecheck, test]
|
needs: [lint, typecheck, test]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-release-v22-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Sync version from tag
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Package Chrome extension
|
|
||||||
run: npm run zip
|
|
||||||
|
|
||||||
- name: Package Firefox extension
|
|
||||||
run: npm run zip:firefox
|
|
||||||
|
|
||||||
- name: Find zip artifacts
|
|
||||||
id: find_zips
|
|
||||||
run: |
|
run: |
|
||||||
CHROME_ZIP=$(find .output -name "*.zip" | grep -v firefox | head -1)
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
FIREFOX_ZIP=$(find .output -name "*.zip" | grep firefox | head -1)
|
echo "Setting version to $VERSION"
|
||||||
echo "chrome_zip=$CHROME_ZIP" >> "$GITHUB_OUTPUT"
|
npm version "$VERSION" --no-git-tag-version
|
||||||
echo "firefox_zip=$FIREFOX_ZIP" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Found Chrome zip: $CHROME_ZIP"
|
- name: Build and Zip Extension
|
||||||
echo "Found Firefox zip: $FIREFOX_ZIP"
|
run: |
|
||||||
|
npm run zip
|
||||||
|
npm run zip:firefox
|
||||||
|
|
||||||
|
# 🔍 调试:确认 zip 文件位置
|
||||||
|
- name: Verify output files
|
||||||
|
run: |
|
||||||
|
echo "=== .output/ contents ==="
|
||||||
|
ls -la .output/ || echo "Not found"
|
||||||
|
echo "=== All zip files ==="
|
||||||
|
find . -name "*.zip" -type f
|
||||||
|
|
||||||
|
# 💡 关键修复:将 zip 文件复制到固定目录,避免 glob 问题
|
||||||
|
- name: Prepare release artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p release-archives
|
||||||
|
cp .output/*.zip release-archives/ 2>/dev/null || true
|
||||||
|
echo "Files in release-archives:"
|
||||||
|
ls -la release-archives/
|
||||||
|
|
||||||
|
- name: Upload Extension Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: release-archives/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# ── Phase 4: 发布 ───────────────────────
|
||||||
|
release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-extension
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Extension Artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: extension-zips
|
||||||
|
path: release-artifacts
|
||||||
|
|
||||||
|
# 🔍 调试:确认下载成功
|
||||||
|
- name: Verify downloaded artifacts
|
||||||
|
run: |
|
||||||
|
echo "=== release-artifacts/ ==="
|
||||||
|
ls -la release-artifacts/
|
||||||
|
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: version
|
id: version
|
||||||
@@ -114,5 +159,4 @@ jobs:
|
|||||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
${{ steps.find_zips.outputs.chrome_zip }}
|
release-artifacts/*.zip
|
||||||
${{ steps.find_zips.outputs.firefox_zip }}
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ stats.html
|
|||||||
stats-*.json
|
stats-*.json
|
||||||
.wxt
|
.wxt
|
||||||
.vitest
|
.vitest
|
||||||
.claude
|
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -28,3 +27,8 @@ stats-*.json
|
|||||||
|
|
||||||
.trae/*
|
.trae/*
|
||||||
.workbuddy/*
|
.workbuddy/*
|
||||||
|
.qoder/*
|
||||||
|
dev/*
|
||||||
|
|
||||||
|
docs/*
|
||||||
|
!docs/VISUAL_STYLE_GUIDE.md
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
# 后台运行类型检查,结果输出到 stderr 但不阻塞提交
|
||||||
|
npx tsc --noEmit &
|
||||||
|
|
||||||
|
# 前台运行 lint-staged(自动修复 + 格式化)
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# pre-push: 严格检查,阻塞有问题的代码推送到远程
|
||||||
|
# 类型检查
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# ESLint 严格检查(不允许 warning)
|
||||||
|
npx eslint . --max-warnings=0
|
||||||
@@ -1,315 +1,153 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown 等测试效率工具。
|
||||||
|
|
||||||
## 项目概述
|
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供多种测试工具功能,包括时间戳转换、存储管理、URL 管理、二维码生成、表单识别与填充等。
|
|
||||||
|
|
||||||
## 核心命令
|
## 核心命令
|
||||||
|
|
||||||
### 开发相关
|
|
||||||
|
|
||||||
- `npm run dev` - 启动 Chrome 浏览器的开发模式
|
|
||||||
- `npm run dev:firefox` - 启动 Firefox 浏览器的开发模式
|
|
||||||
- `npm run build` - 构建 Chrome 浏览器的生产版本
|
|
||||||
- `npm run build:firefox` - 构建 Firefox 浏览器的生产版本
|
|
||||||
- `npm run zip` - 打包 Chrome 扩展
|
|
||||||
- `npm run zip:firefox` - 打包 Firefox 扩展
|
|
||||||
- `npm run compile` - TypeScript 类型检查(不生成文件)
|
|
||||||
- `npm run lint` - 运行 ESLint 检查
|
|
||||||
|
|
||||||
### 测试相关
|
|
||||||
|
|
||||||
- `npm run test` - 运行所有测试(单次执行)
|
|
||||||
- `npm run test:watch` - 运行测试并监听文件变化
|
|
||||||
- `npm run test:coverage` - 运行测试并生成覆盖率报告
|
|
||||||
|
|
||||||
**运行单个测试文件:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx vitest run components/__tests__/CopyButton.test.tsx
|
npm run dev # Chrome 开发模式 (HMR)
|
||||||
|
npm run dev:firefox # Firefox 开发模式
|
||||||
|
npm run build # Chrome 生产构建
|
||||||
|
npm run build:firefox # Firefox 生产构建
|
||||||
|
npm run zip # 打包 Chrome 扩展 (.output/*.zip)
|
||||||
|
npm run zip:firefox # 打包 Firefox 扩展
|
||||||
|
npm run lint # ESLint (--max-warnings=0)
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run test # vitest run (单次执行)
|
||||||
|
npm run test:watch # vitest 监视模式
|
||||||
|
npm run test:coverage # 带覆盖率的测试
|
||||||
```
|
```
|
||||||
|
|
||||||
**测试技术栈:**
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
- Vitest v2 - 测试框架
|
## 验证流程
|
||||||
- @testing-library/react v16 - React 组件测试
|
|
||||||
- @testing-library/user-event v14 - 用户交互模拟
|
|
||||||
- jsdom v25 - 浏览器环境模拟
|
|
||||||
|
|
||||||
### 依赖与准备
|
CI 步骤(严格顺序,任一步骤失败则停止并标记 CI 失败):
|
||||||
|
|
||||||
- `npm install` - 安装依赖
|
1. `setup`(安装依赖、`wxt prepare`)
|
||||||
- `postinstall` 会自动运行 `wxt prepare` 准备开发环境
|
2. 并行运行 `lint`、`typecheck`、`test`(三者全部通过才继续)
|
||||||
- `prepare` 钩子会初始化 Husky Git 钩子
|
3. `build`(仅当步骤 2 全部成功时执行)
|
||||||
|
|
||||||
## 项目架构
|
Pre-commit hook(`.husky/pre-commit` 调用 `lint-staged`,任一步骤返回非零则终止提交):
|
||||||
|
|
||||||
### 技术栈
|
1. 代码文件 (`*.{ts,tsx,js,jsx,mjs}`):运行 `eslint --fix --max-warnings=0 --no-warn-ignored`;若失败则终止并报告错误
|
||||||
|
2. 同一代码文件:运行 `prettier --write`
|
||||||
|
3. 其他文件 (`*.{json,css,scss,md}`):运行 `prettier --write`
|
||||||
|
|
||||||
- **框架**: WXT v0.20.6 (Web Extension Toolkit) - 浏览器扩展开发框架
|
## WXT 生成文件
|
||||||
- **前端**: React 19 + TypeScript 5
|
|
||||||
- **UI 库**: Material UI (MUI) v7 + Emotion
|
|
||||||
- **状态管理**: React Hooks + 自定义 Hooks
|
|
||||||
- **路由**: 自定义路由系统(支持 popup/sidepanel/detached 三种模式)
|
|
||||||
- **测试**: Vitest + Testing Library
|
|
||||||
- **代码质量**: ESLint v9 + Prettier + Husky + lint-staged
|
|
||||||
|
|
||||||
### 目录结构
|
- `.wxt/` 目录由 `postinstall` 自动执行 `wxt prepare` 生成,包含 TypeScript 类型声明和扩展的 tsconfig。
|
||||||
|
- 生产构建输出到 `.output/` 目录。
|
||||||
|
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
├── components/ # 可复用 UI 组件
|
src/ # 源代码根目录
|
||||||
│ ├── __tests__/ # 组件测试文件
|
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||||
│ ├── Button.tsx # 按钮组件
|
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
|
||||||
│ ├── CopyButton.tsx # 复制按钮组件
|
pages/ # 功能页面组件 (懒加载)
|
||||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件
|
components/ # 可复用 UI 组件
|
||||||
│ ├── FieldList.tsx # 字段列表组件
|
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||||
│ ├── GlobalSnackbar.tsx # 全局提示消息组件
|
providers/ # React Context (Router, Theme 等)
|
||||||
│ ├── PageHeader.tsx # 页面头部组件
|
hooks/ # 自定义 React Hooks
|
||||||
│ ├── QrCodeToUrlSection.tsx # 二维码解析为 URL 组件
|
utils/ # 工具函数与服务抽象
|
||||||
│ ├── QrCodeUploader.tsx # 二维码上传组件
|
types/ # TypeScript 类型声明
|
||||||
│ ├── RouterContainer.tsx # 路由容器组件
|
lib/ # 通用工具函数(cn、utils)
|
||||||
│ ├── StorageCleanerConfirm.tsx # 存储清理确认组件
|
public/ # 静态资源(图标、_locales 等)
|
||||||
│ ├── ToolCard.tsx # 工具卡片组件
|
|
||||||
│ ├── TopBar.tsx # 顶部导航栏组件
|
|
||||||
│ ├── UrlEntryForm.tsx # URL 录入表单组件
|
|
||||||
│ ├── UrlEntryItem.tsx # URL 条目组件
|
|
||||||
│ ├── UrlEntryList.tsx # URL 列表组件
|
|
||||||
│ └── UrlToQrCodeSection.tsx # URL 转二维码组件
|
|
||||||
├── config/ # 配置文件
|
|
||||||
│ ├── __tests__/ # 配置测试文件
|
|
||||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置
|
|
||||||
│ ├── pageTheme.ts # 页面主题配置
|
|
||||||
│ ├── routes.ts # 路由配置
|
|
||||||
│ └── theme.ts # 全局主题配置
|
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
|
||||||
│ ├── background.ts # 后台脚本(主进程)
|
|
||||||
│ ├── content.ts # 内容脚本(注入到页面)
|
|
||||||
│ ├── content/
|
|
||||||
│ │ └── messageHandler.ts # 消息处理器
|
|
||||||
│ ├── options/ # 选项页面
|
|
||||||
│ │ ├── App.tsx # 选项应用
|
|
||||||
│ │ ├── index.html # 选项页面 HTML
|
|
||||||
│ │ └── main.tsx # 选项页面入口
|
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
|
||||||
│ │ ├── App.tsx # 弹窗主应用
|
|
||||||
│ │ ├── main.tsx # 弹窗入口
|
|
||||||
│ │ ├── index.html # 弹窗 HTML
|
|
||||||
│ │ ├── pages/ # 弹窗页面
|
|
||||||
│ │ │ ├── components/ # 页面级组件
|
|
||||||
│ │ │ │ ├── AutoRefreshToggle.tsx # 自动刷新开关
|
|
||||||
│ │ │ │ ├── CleaningResult.tsx # 清理结果展示
|
|
||||||
│ │ │ │ ├── DomainHeader.tsx # 域名头部
|
|
||||||
│ │ │ │ ├── ErrorDisplay.tsx # 错误显示
|
|
||||||
│ │ │ │ ├── LiveClock.tsx # 实时时钟
|
|
||||||
│ │ │ │ ├── OptionItem.tsx # 选项条目
|
|
||||||
│ │ │ │ ├── ResultView.tsx # 结果视图
|
|
||||||
│ │ │ │ └── StorageOptionsGrid.tsx # 存储选项网格
|
|
||||||
│ │ │ ├── hooks/ # 自定义 Hooks
|
|
||||||
│ │ │ │ ├── useActiveTabDomain.ts # 当前标签页域名
|
|
||||||
│ │ │ │ ├── useFormRecognizer.ts # 表单识别
|
|
||||||
│ │ │ │ ├── useSidePanelState.ts # 侧边栏状态
|
|
||||||
│ │ │ │ └── useTimestampConverter.ts # 时间戳转换
|
|
||||||
│ │ │ ├── DashboardPage.tsx # 仪表盘页面
|
|
||||||
│ │ │ ├── FormFillPage.tsx # 表单填充页面
|
|
||||||
│ │ │ ├── FormMappingPage.tsx # 表单映射页面
|
|
||||||
│ │ │ ├── FormRecognizerPage.tsx # 表单识别页面
|
|
||||||
│ │ │ ├── OpenUrlPage.tsx # 打开 URL 页面
|
|
||||||
│ │ │ ├── OpenUrlViewerPage.tsx # URL 查看页面
|
|
||||||
│ │ │ ├── QrCodePage.tsx # 二维码页面
|
|
||||||
│ │ │ ├── StorageCleanerPage.tsx # 存储清理页面
|
|
||||||
│ │ │ ├── TimestampPage.tsx # 时间戳页面
|
|
||||||
│ │ │ └── useStorageCleaner.ts # 存储清理 Hook
|
|
||||||
│ └── sidepanel/ # 侧边栏界面
|
|
||||||
│ ├── App.tsx # 侧边栏应用
|
|
||||||
│ ├── index.html # 侧边栏 HTML
|
|
||||||
│ └── main.tsx # 侧边栏入口
|
|
||||||
├── providers/ # React Providers
|
|
||||||
│ └── RouterProvider.tsx # 路由 Provider
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── __tests__/ # 工具测试文件
|
|
||||||
│ ├── formMapping/ # 表单映射工具
|
|
||||||
│ │ ├── highlighter.ts # 表单高亮器
|
|
||||||
│ │ ├── scanner.ts # 表单扫描器
|
|
||||||
│ │ ├── smartInjector.ts # 智能注入器
|
|
||||||
│ │ └── ui.ts # UI 工具
|
|
||||||
│ ├── chromeStorage.ts # Chrome 存储工具
|
|
||||||
│ ├── chromeTabs.ts # Chrome 标签页工具
|
|
||||||
│ ├── clipboard.ts # 剪贴板工具
|
|
||||||
│ ├── dataTemplate.ts # 数据模板
|
|
||||||
│ ├── dataValidator.ts # 数据验证器
|
|
||||||
│ ├── dayjs.ts # 日期处理工具
|
|
||||||
│ ├── dummyDataGenerator.ts # 虚拟数据生成器(基于 Faker)
|
|
||||||
│ ├── messages.ts # 消息通信工具
|
|
||||||
│ ├── qrCodeParser.ts # 二维码解析器
|
|
||||||
│ ├── storageCleaner.ts # 存储清理工具
|
|
||||||
│ ├── useStorageState.ts # 存储状态 Hook
|
|
||||||
│ └── useUrlPreferences.ts # URL 偏好设置 Hook
|
|
||||||
├── types/ # 类型定义
|
|
||||||
│ └── storage.d.ts # 存储相关类型
|
|
||||||
├── docs/ # 文档
|
|
||||||
│ └── plans/ # 计划文档
|
|
||||||
├── public/ # 静态资源
|
|
||||||
│ └── icon/ # 扩展图标
|
|
||||||
└── .github/ # GitHub 配置
|
|
||||||
└── workflows/ # CI/CD 工作流
|
|
||||||
├── ci.yml # 持续集成
|
|
||||||
└── release.yml # 发布流程
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 核心功能模块
|
### 页面组件模式
|
||||||
|
|
||||||
#### 1. 时间戳转换工具
|
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/TimestampPage.tsx`
|
```
|
||||||
- Hook: `entrypoints/popup/pages/hooks/useTimestampConverter.ts`
|
src/pages/FeatureName/
|
||||||
- 依赖: dayjs 库进行日期处理
|
├── index.tsx # 页面 UI(纯展示,仅负责渲染布局)
|
||||||
- 功能: 支持日期与时间戳的双向转换,支持多种格式,实时时钟显示
|
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
|
||||||
|
├── constants.ts # 常量定义(可选,≥3 个常量时创建)
|
||||||
#### 2. 存储清理工具
|
└── __tests__/
|
||||||
|
└── index.test.tsx
|
||||||
- 位置: `entrypoints/popup/pages/StorageCleanerPage.tsx`
|
|
||||||
- Hook: `entrypoints/popup/pages/useStorageCleaner.ts`
|
|
||||||
- 工具: `utils/storageCleaner.ts`
|
|
||||||
- 功能: 清理缓存、Cookies、本地存储,支持按域名筛选,自动刷新功能
|
|
||||||
|
|
||||||
#### 3. URL 管理工具
|
|
||||||
|
|
||||||
- 打开 URL: `entrypoints/popup/pages/OpenUrlPage.tsx`
|
|
||||||
- 查看 URL: `entrypoints/popup/pages/OpenUrlViewerPage.tsx`
|
|
||||||
- 组件: `components/UrlEntryForm.tsx`, `components/UrlEntryList.tsx`
|
|
||||||
- 功能: 批量打开多个 URL,URL 列表管理
|
|
||||||
|
|
||||||
#### 4. 二维码工具
|
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/QrCodePage.tsx`
|
|
||||||
- 组件: `components/QrCodeUploader.tsx`, `components/QrCodeToUrlSection.tsx`, `components/UrlToQrCodeSection.tsx`
|
|
||||||
- 工具: `utils/qrCodeParser.ts`
|
|
||||||
- 依赖: qrcode, jsqr 库
|
|
||||||
- 功能: URL 转二维码生成,二维码图片解析为 URL
|
|
||||||
|
|
||||||
#### 5. 表单工具套件
|
|
||||||
|
|
||||||
**表单识别 (Form Recognizer)**
|
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/FormRecognizerPage.tsx`
|
|
||||||
- Hook: `entrypoints/popup/pages/hooks/useFormRecognizer.ts`
|
|
||||||
- 功能: 智能识别页面表单指纹
|
|
||||||
|
|
||||||
**表单映射 (Form Mapping)**
|
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/FormMappingPage.tsx`
|
|
||||||
- 工具: `utils/formMapping/` 目录
|
|
||||||
- `scanner.ts` - 表单扫描器
|
|
||||||
- `highlighter.ts` - 表单高亮器
|
|
||||||
- `smartInjector.ts` - 智能注入器
|
|
||||||
- `ui.ts` - UI 工具
|
|
||||||
- 功能: 表单指纹识别与自定义映射规则配置
|
|
||||||
|
|
||||||
**表单填充 (Form Fill)**
|
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/FormFillPage.tsx`
|
|
||||||
- 工具: `utils/dummyDataGenerator.ts` (基于 @faker-js/faker)
|
|
||||||
- 功能: 根据表单指纹智能填充表单数据
|
|
||||||
|
|
||||||
#### 6. 仪表盘系统
|
|
||||||
|
|
||||||
- 位置: `entrypoints/popup/pages/DashboardPage.tsx`
|
|
||||||
- 配置: `config/features.tsx`
|
|
||||||
- 组件: `components/DashboardCard.tsx`, `components/ToolCard.tsx`
|
|
||||||
- 功能: 统一工具入口,可自定义显示的工具卡片
|
|
||||||
|
|
||||||
#### 7. 多模式显示系统
|
|
||||||
|
|
||||||
- 支持三种显示模式:
|
|
||||||
- **popup** - 扩展弹窗(点击图标显示)
|
|
||||||
- **sidepanel** - 浏览器侧边栏
|
|
||||||
- **detached** - 独立窗口模式
|
|
||||||
- 路由配置: `config/features.tsx`
|
|
||||||
- 路由容器: `components/RouterContainer.tsx`
|
|
||||||
- Provider: `providers/RouterProvider.tsx`
|
|
||||||
|
|
||||||
#### 8. 通信系统
|
|
||||||
|
|
||||||
- 位置: `utils/messages.ts`
|
|
||||||
- 机制: 使用 `@webext-core/messaging` 库实现
|
|
||||||
- 内容脚本消息处理: `entrypoints/content/messageHandler.ts`
|
|
||||||
- 通信通道: 后台脚本 ↔ 内容脚本 ↔ 弹窗/侧边栏
|
|
||||||
|
|
||||||
#### 9. 数据存储
|
|
||||||
|
|
||||||
- Chrome Storage API: `utils/chromeStorage.ts`
|
|
||||||
- 存储状态 Hook: `utils/useStorageState.ts`
|
|
||||||
- URL 偏好设置: `utils/useUrlPreferences.ts`
|
|
||||||
- 类型定义: `types/storage.d.ts`
|
|
||||||
|
|
||||||
### 关键配置文件
|
|
||||||
|
|
||||||
#### wxt.config.ts
|
|
||||||
|
|
||||||
- 配置 WXT 框架参数
|
|
||||||
- 启用 React 模块
|
|
||||||
- 配置浏览器扩展权限(storage, unlimitedStorage, clipboardWrite, activeTab, scripting, tabs, cookies, sidePanel)
|
|
||||||
- Vite 构建配置(使用 Terser 压缩,强制 ASCII 编码)
|
|
||||||
- 配置侧边栏和选项页面
|
|
||||||
|
|
||||||
#### manifest 权限
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
permissions: [
|
|
||||||
'storage', // 存储权限
|
|
||||||
'unlimitedStorage', // 无限制存储
|
|
||||||
'clipboardWrite', // 剪贴板写入
|
|
||||||
'activeTab', // 当前标签页
|
|
||||||
'scripting', // 脚本注入
|
|
||||||
'tabs', // 标签页管理
|
|
||||||
'cookies', // Cookies 管理
|
|
||||||
'sidePanel', // 侧边栏
|
|
||||||
],
|
|
||||||
host_permissions:['<all_urls>'] // 访问所有网站
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### CI/CD 配置
|
- 页面入口组件统一命名为 `Index`,通过 `export default function Index()` 导出
|
||||||
|
- Hook 负责所有状态管理和业务逻辑,通过返回值暴露给页面
|
||||||
|
- 子组件可以独立调用 `useI18n` 等全局 Hook
|
||||||
|
- 当 `index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
|
||||||
|
- 复杂页面可增加 `contexts/`、`hooks/`、`components/` 子目录
|
||||||
|
|
||||||
- `.github/workflows/ci.yml` - 持续集成工作流
|
## 关键架构决策
|
||||||
- `.github/workflows/release.yml` - 发布工作流
|
|
||||||
|
|
||||||
## 开发注意事项
|
**路由**: 不使用 React Router。通过 `src/config/features.tsx` 的 `FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
|
||||||
|
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
|
||||||
|
每种模式有独立的路由和可见页面配置(`app/popupRoute`、`app/sidepanelRoute`、`app/tabRoute` 等)。
|
||||||
|
|
||||||
### 扩展入口点
|
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts` 的 `StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
|
||||||
|
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local` 和 `localStorage` 做快照以消除首屏闪烁。
|
||||||
|
修改 StorageSchema 时,必须在 `src/utils/chromeStorage.ts` 添加版本迁移函数,并在测试中覆盖迁移场景。
|
||||||
|
|
||||||
- **后台脚本**: `entrypoints/background.ts` - 处理扩展生命周期和后台任务
|
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`。
|
||||||
- **内容脚本**: `entrypoints/content.ts` - 注入到网页中,处理 DOM 交互
|
|
||||||
- **弹窗**: `entrypoints/popup/main.tsx` - 用户点击扩展图标时显示
|
|
||||||
- **侧边栏**: `entrypoints/sidepanel/main.tsx` - 浏览器侧边栏界面
|
|
||||||
- **选项页面**: `entrypoints/options/main.tsx` - 扩展设置页面
|
|
||||||
|
|
||||||
### 路由系统
|
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)。
|
||||||
|
|
||||||
- 使用自定义路由系统,支持多种显示模式
|
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
|
||||||
- 路由配置在 `config/features.tsx`
|
|
||||||
- 通过 `getEntryPointType()` 判断当前入口点类型
|
|
||||||
- 支持页面可见性配置(`defaultVisible`)
|
|
||||||
|
|
||||||
### 浏览器兼容性
|
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。
|
||||||
|
|
||||||
- 支持 Chrome 和 Firefox 浏览器
|
## 测试环境
|
||||||
- 使用 WXT 框架抽象浏览器差异
|
|
||||||
- 使用 `@types/chrome` 和 `@types/webextension-polyfill` 提供类型支持
|
|
||||||
|
|
||||||
### 代码质量
|
- 环境: jsdom
|
||||||
|
- 全局变量: `vitest/globals` (describe, it, expect 等无需导入)
|
||||||
|
- Setup 文件: `vitest.setup.ts` 自动 mock:
|
||||||
|
- `chrome.*` / `browser.*` API (storage, tabs, runtime, cookies 等)
|
||||||
|
- `@/utils/chromeI18n` (从 `public/_locales/zh/messages.json` 加载真实翻译)
|
||||||
|
- `window.matchMedia`
|
||||||
|
- 测试文件命名: `__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||||
|
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
|
||||||
|
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
|
||||||
|
|
||||||
- 使用 ESLint v9 进行代码检查(基于 typescript-eslint)
|
## i18n (chrome.i18n)
|
||||||
- Prettier 进行代码格式化
|
|
||||||
- Husky v9 用于 Git 钩子管理
|
|
||||||
- Lint-staged 确保暂存文件符合规范
|
|
||||||
- GitHub Actions CI/CD 自动化测试和构建
|
|
||||||
|
|
||||||
### 测试策略
|
项目使用 Chrome 扩展标准的 `chrome.i18n` API 进行本地化,通过 `src/utils/chromeI18n.ts` 提供类型安全的 React Hook 包装。
|
||||||
|
|
||||||
- 组件测试: `components/__tests__/` 目录
|
- **翻译文件**: `public/_locales/{zh,en}/messages.json`(Chrome 扩展标准格式)
|
||||||
- 工具函数测试: `utils/__tests__/` 目录
|
- **默认语言**: `zh`(在 `wxt.config.ts` 的 `manifest.default_locale` 中配置)
|
||||||
- 配置测试: `config/__tests__/` 目录
|
- **使用方式**: `import { useI18n } from '@/utils/chromeI18n'`
|
||||||
- 使用 Vitest 作为测试框架
|
- **翻译键格式**:
|
||||||
- 使用 Testing Library 进行 React 组件测试
|
- 直接 key: `t('dashboard_title')` → 查找 `dashboard_title`
|
||||||
|
- 命名空间格式(兼容旧用法): `t('common:buttons.search')` → 查找 `common_buttons_search`
|
||||||
|
- 带命名空间参数: `useI18n(['common', 'features'])`,会自动尝试 `common_key`、`features_key`
|
||||||
|
- **占位符支持**: `t('router_notFoundDescription', { entryPointType: 'popup' })`
|
||||||
|
- **Hook 返回值**: `{ t, i18n: { language, changeLanguage }, isLoaded }`
|
||||||
|
- **回退策略**: 当翻译 key 未命中时,返回 key 本身(开发模式下在控制台记录 warning)
|
||||||
|
- **限制**: `chrome.i18n` 无法动态切换语言,语言跟随浏览器设置,切换后需刷新页面
|
||||||
|
|
||||||
|
## 新功能开发清单
|
||||||
|
|
||||||
|
1. 在 `src/types/storage.d.ts` 添加 `PageType` 联合类型
|
||||||
|
2. 在 `src/config/features.tsx` 的 `FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
|
||||||
|
3. 在 `src/pages/` 创建页面组件 (懒加载):
|
||||||
|
- `index.tsx` — UI 组件,使用 `useI18n` 获取翻译
|
||||||
|
- `useFeatureName.ts` — 业务逻辑 Hook
|
||||||
|
- `constants.ts` — 常量(可选)
|
||||||
|
4. 在 `public/_locales/zh/messages.json`(及 `en/messages.json`)添加翻译
|
||||||
|
5. 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
|
||||||
|
6. 添加对应的单元测试
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
- 禁止使用 `any` (测试文件除外)
|
||||||
|
- 未使用变量/参数: 使用 `_` 前缀 (如 `_unused`)
|
||||||
|
- 样式: 使用 Tailwind CSS + shadcn/ui (通过 `className` 和 `cn()` 工具)
|
||||||
|
- UI 组件: 优先使用 `src/components/ui/` 下的 shadcn/ui 组件 (button, dialog, select 等)
|
||||||
|
- 图标: 使用 `lucide-react` 图标库
|
||||||
|
- 格式: Prettier (`.prettierrc`: 100 字符宽, 单引号, 尾逗号 all, LF 换行)
|
||||||
|
- ESLint 使用 `typescript-eslint` 的 `projectService: true`(无需手动维护 project 路径)
|
||||||
|
|
||||||
|
## 关键外部库(非显而易见的)
|
||||||
|
|
||||||
|
- `@webext-core/messaging` — 扩展消息通信
|
||||||
|
- `@dnd-kit` — 拖拽排序(用于页面顺序管理)
|
||||||
|
- `qrious` + `qr-scanner` — 二维码生成与解析
|
||||||
|
- `dayjs` — 日期处理(时间戳转换)
|
||||||
|
- `sonner` — Toast 通知(替代传统 snackbar)
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# Testing Tools Browser Extension - Gemini Instructions
|
|
||||||
|
|
||||||
This document provides essential context and instructions for AI agents working on the Testing Tools browser extension project.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
**Testing Tools** is a lightweight, feature-rich browser extension built with the [WXT (Web Extension Toolkit)](https://wxt.dev/) framework. It provides a suite of utilities for developers and testers, including timestamp conversion, storage management, URL shortcuts, and QR code tools.
|
|
||||||
|
|
||||||
### Tech Stack
|
|
||||||
|
|
||||||
- **Framework:** WXT (Web Extension Toolkit)
|
|
||||||
- **Frontend:** React 19 + TypeScript
|
|
||||||
- **UI Library:** Material UI (MUI) @7.x
|
|
||||||
- **Date Handling:** dayjs (with UTC and timezone plugins)
|
|
||||||
- **Messaging:** @webext-core/messaging
|
|
||||||
- **Storage:** Type-safe Chrome Storage API wrapper
|
|
||||||
- **Testing:** Vitest + Testing Library (jsdom)
|
|
||||||
|
|
||||||
### Architecture & Directory Structure
|
|
||||||
|
|
||||||
- `entrypoints/`: Extension entry points (popup, options, sidepanel, background, content).
|
|
||||||
- `popup/`: Main UI shown when clicking the extension icon.
|
|
||||||
- `options/`: Extension settings page.
|
|
||||||
- `sidepanel/`: Browser side panel integration.
|
|
||||||
- `background.ts`: Background script for lifecycle management and background tasks.
|
|
||||||
- `content.ts`: Content script injected into web pages.
|
|
||||||
- `components/`: Reusable React components.
|
|
||||||
- `config/`: Application configuration, including routes and themes.
|
|
||||||
- `providers/`: React Context providers (e.g., `RouterProvider`).
|
|
||||||
- `utils/`: Utility functions and service abstractions.
|
|
||||||
- `chromeStorage.ts`: Type-safe storage utility.
|
|
||||||
- `types/`: Global TypeScript type definitions.
|
|
||||||
- `public/`: Static assets (icons, etc.).
|
|
||||||
|
|
||||||
## Building and Running
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- `npm run dev`: Start Chrome development mode with HMR.
|
|
||||||
- `npm run dev:firefox`: Start Firefox development mode.
|
|
||||||
- `npm run compile`: Run TypeScript type checking (`tsc --noEmit`).
|
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
- `npm run build`: Build production version for Chrome.
|
|
||||||
- `npm run build:firefox`: Build production version for Firefox.
|
|
||||||
- `npm run zip`: Package the extension for Chrome Web Store.
|
|
||||||
- `npm run zip:firefox`: Package the extension for Firefox Add-ons.
|
|
||||||
|
|
||||||
### Testing & Linting
|
|
||||||
|
|
||||||
- `npm run test`: Run all tests once.
|
|
||||||
- `npm run test:watch`: Run tests in watch mode.
|
|
||||||
- `npm run test:coverage`: Run tests and generate coverage report.
|
|
||||||
- `npm run lint`: Run ESLint checks.
|
|
||||||
|
|
||||||
## Development Conventions
|
|
||||||
|
|
||||||
### Coding Style
|
|
||||||
|
|
||||||
- **TypeScript:** Use strict typing. Prefer interfaces for object structures and types for unions/aliases.
|
|
||||||
- **Components:** Functional components with Hooks. Use MUI components for consistent UI.
|
|
||||||
- **Storage:** Always use `storageUtil` from `@/utils/chromeStorage.ts` for accessing `chrome.storage.local`. Ensure keys are defined in `StorageSchema` in `@/types/storage.d.ts`.
|
|
||||||
- **Messaging:** Use `@webext-core/messaging` for communication between entry points. Define message types in `@/utils/messages.ts`.
|
|
||||||
|
|
||||||
### Testing Practices
|
|
||||||
|
|
||||||
- **Framework:** Vitest with `jsdom` environment.
|
|
||||||
- **Location:** Place tests in `__tests__` directories adjacent to the files being tested.
|
|
||||||
- **Naming:** Follow `*.test.ts` or `*.test.tsx` naming convention.
|
|
||||||
- **Patterns:** Use `@testing-library/react` for component testing. Prefer `user-event` (v14+) for simulating interactions.
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- **GitHub Actions:** CI runs on push/PR to `main` and `develop` branches (lint, compile, test, build).
|
|
||||||
- **Releases:** Automatic release to GitHub on pushing a `v*` tag.
|
|
||||||
|
|
||||||
## Key Considerations for AI Agents
|
|
||||||
|
|
||||||
- **Manifest Permissions:** When adding features that require new browser APIs, update `wxt.config.ts`.
|
|
||||||
- **Browser Compatibility:** Ensure features work in both Chrome and Firefox.
|
|
||||||
- **React 19:** Be aware of React 19 specific features and deprecations.
|
|
||||||
- **WXT Modules:** The project uses `@wxt-dev/module-react`.
|
|
||||||
@@ -1,258 +1,153 @@
|
|||||||
# Testing Tools Browser Extension
|
# Testing Tools Browser Extension
|
||||||
|
|
||||||
这是一个基于 WXT 框架的浏览器扩展项目,提供实用的测试工具功能。
|
这是一个基于 WXT 框架的浏览器扩展项目,为开发者和测试人员提供实用的效率工具.
|
||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Testing Tools 是一个轻量级的浏览器扩展,提供多种实用的测试工具功能。项目采用现代化的技术栈,包括 React 19、TypeScript 和 Material UI,并利用 WXT 框架简化浏览器扩展的开发流程。
|
**Testing Tools** 是一个轻量级、功能丰富的浏览器扩展,采用现代化的技术栈构建. 它旨在简化日常开发和测试任务,如时间戳转换、存储管理、JWT 解析等. 项目利用 [WXT (Web Extension Toolkit)](https://wxt.dev/) 框架,提供了卓越的开发体验和跨浏览器支持.
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
### Dashboard 首页
|
### 🚀 Dashboard 首页
|
||||||
|
|
||||||
- 卡片式工具展示
|
- **工具导航**: 快速访问所有可用工具.
|
||||||
- 支持自定义工具排序和可见性
|
- **个性化定制**: 支持自定义工具的排序和可见性.
|
||||||
- 实时数据预览(时间戳等)
|
- **实时预览**: 在卡片上直接查看实时数据(如当前时间戳).
|
||||||
|
|
||||||
### 时间戳转换工具
|
### ⏰ 时间戳转换工具
|
||||||
|
|
||||||
- 实时显示当前时间戳(毫秒/秒可切换)
|
- **实时显示**: 毫秒级精度显示当前系统时间.
|
||||||
- 日期与时间戳之间的双向转换
|
- **双向转换**: 日期字符串与 Unix 时间戳(秒/毫秒)之间的无缝转换.
|
||||||
- 支持多个时区(亚洲/上海、美洲/纽约、欧洲/伦敦)
|
- **多时区支持**: 预设常用时区(亚洲/上海、美洲/纽约、欧洲/伦敦),支持快速切换.
|
||||||
- 一键复制转换结果
|
- **快捷操作**: 一键复制转换结果,支持多种格式.
|
||||||
- 输入验证和错误提示
|
|
||||||
|
|
||||||
### 存储清理工具
|
### 🧹 存储清理工具
|
||||||
|
|
||||||
- 自动读取当前域名
|
- **智能识别**: 自动检测并显示当前活动标签页的域名.
|
||||||
- 支持清理多种存储类型:
|
- **全面清理**: 支持一键清理 localStorage、sessionStorage、IndexedDB、Cookies、Cache Storage 和 Service Workers.
|
||||||
- localStorage
|
- **细粒度控制**: 可根据需要选择特定的清理项.
|
||||||
- sessionStorage
|
- **自动刷新**: 提供清理后自动刷新页面的选项,确保状态同步.
|
||||||
- IndexedDB
|
|
||||||
- Cookies
|
|
||||||
- Cache Storage
|
|
||||||
- Service Workers
|
|
||||||
- 可选择的清理类型(默认全选)
|
|
||||||
- 确认对话框防止误操作
|
|
||||||
- 清理结果统计
|
|
||||||
- 自动刷新页面选项
|
|
||||||
|
|
||||||
### URL 工具
|
### 📝 文本统计工具
|
||||||
|
|
||||||
- 保存常用 URL 列表
|
- **实时分析**: 键入即统计,无需额外操作.
|
||||||
- 快速打开保存的 URL
|
- **多维指标**: 统计字符数、单词数、行数以及精确的字节大小.
|
||||||
- 支持 URL 验证和安全检查
|
- **性能优化**: 采用高性能分词算法,支持大文本处理.
|
||||||
- 内置 URL 查看器(iframe 沙箱模式)
|
|
||||||
|
|
||||||
### 二维码工具
|
### 🔑 JWT 解析工具
|
||||||
|
|
||||||
- URL 转二维码(生成器)
|
- **快速解码**: 自动解析 JSON Web Token 的 Header 和 Payload.
|
||||||
- 二维码转 URL(解析器)
|
- **格式化显示**: 以着色和格式化的 JSON 视图展示数据,方便阅读.
|
||||||
- 支持上传二维码图片解析
|
- **安全检查**: 自动去除 `Bearer` 前缀,处理异常输入并提供友好提示.
|
||||||
- 生成的二维码可下载
|
- **签名查看**: 展示 JWT 签名部分,辅助验证令牌完整性.
|
||||||
- 一键复制转换结果
|
|
||||||
- 卡片式布局,节省空间
|
### 🖱️ 右键恢复工具
|
||||||
|
|
||||||
|
- **当前页面检测**: 自动识别当前活动标签页的域名.
|
||||||
|
- **一键恢复**: 解除网站对右键菜单的限制,恢复复制、粘贴等基础操作.
|
||||||
|
- **状态可视化**: 通过 Badge 组件直观展示当前页面的锁定/解锁状态.
|
||||||
|
|
||||||
|
### 🖼️ 二维码工具
|
||||||
|
|
||||||
|
- **生成器**: 将当前 URL 或自定义文本快速转换为二维码,支持下载.
|
||||||
|
- **解析器**: 支持通过上传图片或粘贴图片来解析二维码内容.
|
||||||
|
|
||||||
|
### 🔄 JSON 工具
|
||||||
|
|
||||||
|
- **差异比较**: 对比两段 JSON 数据,高亮展示差异.
|
||||||
|
- **格式化**: 支持 JSON 美化、压缩、转 YAML / TOML.
|
||||||
|
|
||||||
|
### 🧰 Base64 转换器
|
||||||
|
|
||||||
|
- **文本编解码**: 支持文本内容的 Base64 编码与解码.
|
||||||
|
- **文件转换**: 支持文件与 Base64 字符串互转.
|
||||||
|
- **图像预览**: 支持图片 Base64 编码与实时预览.
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **框架**: WXT (Web Extension Toolkit)
|
- **框架**: [WXT (Web Extension Toolkit)](https://wxt.dev/)
|
||||||
- **前端**: React 19 + TypeScript
|
- **前端**: React 19 + TypeScript
|
||||||
- **UI 库**: Material UI
|
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
|
||||||
- **日期处理**: dayjs (含 UTC 和时区插件)
|
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
|
||||||
|
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
|
||||||
|
- **国际化**: i18next + react-i18next
|
||||||
- **通信**: @webext-core/messaging
|
- **通信**: @webext-core/messaging
|
||||||
- **存储**: Chrome Storage API (类型安全封装)
|
- **存储**: Chrome Storage API (类型安全封装)
|
||||||
- **二维码**: qrcode (生成) + jsqr (解析)
|
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
|
||||||
- **测试**: Vitest + Testing Library
|
- **测试**: Vitest + Testing Library
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
├── components/ # 可复用 UI 组件
|
├── src/ # 源代码根目录
|
||||||
│ ├── Button.tsx
|
│ ├── components/ # 可复用 React 组件
|
||||||
│ ├── CopyButton.tsx
|
│ ├── config/ # 应用配置
|
||||||
│ ├── DashboardCard.tsx # 仪表盘卡片组件(React.memo 优化)
|
│ │ └── features.tsx # 功能定义与路由映射
|
||||||
│ ├── GlobalSnackbar.tsx
|
│ ├── entrypoints/ # 扩展程序入口点
|
||||||
│ ├── PageHeader.tsx # 页面标题栏组件
|
│ │ ├── popup/ # 点击图标弹出的主界面
|
||||||
│ ├── RouterContainer.tsx
|
│ │ ├── sidepanel/ # 浏览器侧边栏集成
|
||||||
│ ├── StorageCleanerConfirm.tsx
|
│ │ ├── background.ts # 后台 Service Worker
|
||||||
│ ├── ToolCard.tsx
|
│ │ └── content.ts # 网页注入脚本
|
||||||
│ └── TopBar.tsx
|
│ ├── pages/ # 各功能模块的页面组件
|
||||||
├── config/ # 配置文件
|
│ ├── providers/ # 全局状态提供者 (Router, Theme 等)
|
||||||
│ ├── dashboardCards.tsx # 仪表盘卡片配置数据
|
│ ├── hooks/ # 自定义 React Hooks
|
||||||
│ └── routes.ts # 页面路由定义
|
│ ├── utils/ # 工具函数与服务抽象
|
||||||
├── entrypoints/ # 浏览器扩展入口点
|
│ ├── types/ # TypeScript 类型声明
|
||||||
│ ├── popup/ # 扩展弹窗界面
|
│ └── lib/ # 通用工具函数 (cn, utils 等)
|
||||||
│ │ ├── App.tsx
|
├── public/ # 静态资源 (图标、manifest 资源等)
|
||||||
│ │ ├── main.tsx
|
├── wxt.config.ts # WXT 框架核心配置
|
||||||
│ │ └── pages/ # 页面组件
|
└── package.json # 项目元数据与依赖管理
|
||||||
│ │ ├── DashboardPage.tsx
|
|
||||||
│ │ ├── OpenUrlPage.tsx
|
|
||||||
│ │ ├── OpenUrlViewerPage.tsx
|
|
||||||
│ │ ├── QrCodePage.tsx
|
|
||||||
│ │ ├── StorageCleanerPage.tsx
|
|
||||||
│ │ └── TimestampPage.tsx
|
|
||||||
│ ├── options/ # 选项页面
|
|
||||||
│ ├── sidepanel/ # 侧边栏
|
|
||||||
│ ├── background.ts # 后台脚本
|
|
||||||
│ └── content.ts # 内容脚本
|
|
||||||
├── providers/ # React Context providers
|
|
||||||
│ └── RouterProvider.tsx # 路由状态管理
|
|
||||||
├── types/ # TypeScript 类型定义
|
|
||||||
│ └── storage.d.ts
|
|
||||||
├── utils/ # 工具函数
|
|
||||||
│ ├── chromeStorage.ts
|
|
||||||
│ ├── clipboard.ts
|
|
||||||
│ ├── dayjs.ts
|
|
||||||
│ ├── messages.tsx
|
|
||||||
│ └── storageCleaner.ts
|
|
||||||
├── public/ # 静态资源
|
|
||||||
├── wxt.config.ts # WXT 配置文件
|
|
||||||
├── package.json
|
|
||||||
└── README.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 路由系统
|
## 开发与部署
|
||||||
|
|
||||||
项目实现了灵活的路由系统,支持:
|
### 开发环境要求
|
||||||
|
|
||||||
- **页面导航**: 在不同工具页面之间切换
|
- Node.js >= 18.x
|
||||||
- **路由同步**: 通过 Chrome Storage 同步路由状态
|
- npm 或 pnpm
|
||||||
- **可见性控制**: 可配置显示哪些页面
|
|
||||||
- **页面排序**: 自定义工具卡片的显示顺序
|
|
||||||
|
|
||||||
### 页面类型 (PageType)
|
### 常用命令
|
||||||
|
|
||||||
| 页面 | 说明 | 默认可见 |
|
| 命令 | 说明 |
|
||||||
| ---------------- | ---------- | -------- |
|
| ----------------------- | --------------------------------- |
|
||||||
| `dashboard` | 首页 | ✓ |
|
| `npm run dev` | 启动 Chrome 开发模式(支持 HMR) |
|
||||||
| `timestamp` | 时间戳转换 | ✓ |
|
| `npm run dev:firefox` | 启动 Firefox 开发模式 |
|
||||||
| `storageCleaner` | 存储清理 | ✓ |
|
| `npm run build` | 构建 Chrome 生产版本 |
|
||||||
| `openUrl` | URL 工具 | ✓ |
|
| `npm run build:firefox` | 构建 Firefox 生产版本 |
|
||||||
| `qrCode` | 二维码工具 | ✓ |
|
| `npm run zip` | 打包 Chrome 扩展 (.output/\*.zip) |
|
||||||
| `openUrlViewer` | URL 查看器 | ✗ |
|
| `npm run zip:firefox` | 打包 Firefox 扩展 |
|
||||||
|
| `npm run typecheck` | 执行 TypeScript 类型检查 |
|
||||||
|
| `npm run lint` | 执行 ESLint 代码规范检查 |
|
||||||
|
| `npm run test` | 运行单元测试 |
|
||||||
|
| `npm run test:coverage` | 生成测试覆盖率报告 |
|
||||||
|
|
||||||
## 扩展入口点
|
运行单个测试: `npx vitest run path/to/file.test.ts`
|
||||||
|
|
||||||
| 入口点 | 说明 |
|
### 自动化流程
|
||||||
| -------------- | ------------------------ |
|
|
||||||
| **popup** | 点击扩展图标弹出的界面 |
|
|
||||||
| **options** | 扩展选项页面 |
|
|
||||||
| **sidepanel** | 浏览器侧边栏 |
|
|
||||||
| **background** | 后台脚本(生命周期管理) |
|
|
||||||
| **content** | 内容脚本(注入到网页) |
|
|
||||||
|
|
||||||
## 开发环境要求
|
项目通过 GitHub Actions 实现了完善的 CI/CD 流程:
|
||||||
|
|
||||||
- Node.js >= 18
|
- **CI**: 每次推送或 PR 都会自动执行 Lint、类型检查、测试和构建验证.
|
||||||
- npm 或 yarn
|
- **Release**: 推送以 `v*` 开头的 Tag 会自动打包并创建 GitHub Release.
|
||||||
|
|
||||||
## 安装与运行
|
|
||||||
|
|
||||||
### 1. 安装依赖
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 开发模式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
|
||||||
npm run dev:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 构建生产版本
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
|
||||||
npm run build:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 打包分发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Chrome 浏览器
|
|
||||||
npm run zip
|
|
||||||
|
|
||||||
# Firefox 浏览器
|
|
||||||
npm run zip:firefox
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 代码质量
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run compile # TypeScript 类型检查
|
|
||||||
npm run lint # ESLint 代码检查
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test # 运行所有测试
|
|
||||||
npm run test:watch # 运行测试并监听文件变化
|
|
||||||
npm run test:coverage # 运行测试并生成覆盖率报告
|
|
||||||
```
|
|
||||||
|
|
||||||
## 持续集成与发布
|
|
||||||
|
|
||||||
项目使用 GitHub Actions 实现自动化 CI/CD,无需手动操作。
|
|
||||||
|
|
||||||
### CI — 持续集成
|
|
||||||
|
|
||||||
在以下场景自动触发:
|
|
||||||
|
|
||||||
- push 到 `main` / `develop` / `develop-*` 分支
|
|
||||||
- 所有 PR(合并到 `main` 或 `develop`)
|
|
||||||
|
|
||||||
自动执行:ESLint 检查 → TypeScript 类型检查 → 单元测试 → Chrome & Firefox 构建验证。
|
|
||||||
|
|
||||||
### 发布版本
|
|
||||||
|
|
||||||
只需推送符合 `v*` 格式的 Git tag,即可自动完成全量 CI 检查、打包并发布到 GitHub Release:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git tag v1.0.0
|
|
||||||
git push origin v1.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
> 含 `-` 的 tag(如 `v1.0.0-beta.1`)会自动标记为预发布版本(prerelease)。
|
|
||||||
|
|
||||||
工作流文件位于 `.github/workflows/`:
|
|
||||||
|
|
||||||
- `ci.yml` — 持续集成
|
|
||||||
- `release.yml` — 自动发布
|
|
||||||
|
|
||||||
## 权限说明
|
## 权限说明
|
||||||
|
|
||||||
扩展请求以下权限:
|
本扩展根据功能需要申请了以下权限:
|
||||||
|
|
||||||
- `storage` 和 `unlimitedStorage` - 本地数据存储
|
- `storage` & `unlimitedStorage`: 存储用户设置、工具配置及大量数据.
|
||||||
- `clipboardWrite` - 剪贴板写入(复制功能)
|
- `activeTab` & `tabs`: 获取当前页面 URL 及其元数据.
|
||||||
- `activeTab`, `scripting`, `tabs` - 当前标签页控制和脚本注入
|
- `scripting`: 在网页中执行清理和右键恢复脚本.
|
||||||
- `cookies` - Cookie 访问
|
- `cookies`: 管理和清理网站 Cookie.
|
||||||
- `sidePanel` - 侧边栏支持
|
- `sidePanel`: 支持在浏览器侧边栏中运行.
|
||||||
- `<all_urls>` - 访问所有网站内容(内容脚本注入)
|
- `clipboardWrite`: 提供一键复制功能.
|
||||||
|
- `contextMenus`: 注册右键菜单,支持快捷操作.
|
||||||
|
- `alarms`: 管理后台定时任务(如清理后自动刷新).
|
||||||
|
|
||||||
## 主要依赖
|
## 浏览器支持
|
||||||
|
|
||||||
- `react`, `react-dom` - 前端框架
|
- Chrome (及其它 Chromium 内核浏览器)
|
||||||
- `@mui/material` - UI 组件库
|
|
||||||
- `dayjs` - 日期处理
|
|
||||||
- `@webext-core/messaging` - 扩展消息通信
|
|
||||||
- `vitest` - 测试框架
|
|
||||||
- `@testing-library/react` - React 组件测试
|
|
||||||
|
|
||||||
## 浏览器兼容性
|
|
||||||
|
|
||||||
- Chrome (推荐)
|
|
||||||
- Firefox
|
- Firefox
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
此项目采用 MIT 许可证。详见 [LICENSE](LICENSE) 文件。
|
基于 [MIT License](LICENSE) 开源.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 按钮属性类型
|
|
||||||
* 继承自 MUI ButtonProps,支持所有 MUI Button 的属性
|
|
||||||
*/
|
|
||||||
export type ButtonProps = MuiButtonProps;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Button - 自定义按钮组件
|
|
||||||
*
|
|
||||||
* 基于 MUI Button 的二次封装,提供统一的项目风格:
|
|
||||||
* - 禁用阴影和涟漪效果
|
|
||||||
* - 圆角设计 (borderRadius: 4)
|
|
||||||
* - 固定高度和字体大小
|
|
||||||
* - hover 时轻微上浮效果
|
|
||||||
* - 支持 sx 数组合并
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <Button variant="contained" color="primary">
|
|
||||||
* 提交
|
|
||||||
* </Button>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @param sx - 自定义样式,支持数组或单个样式对象
|
|
||||||
* @param props - 其他 MUI Button 属性
|
|
||||||
* @returns 按钮组件
|
|
||||||
*/
|
|
||||||
export function Button({ sx = [], ...props }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<MuiButton
|
|
||||||
disableElevation
|
|
||||||
disableRipple
|
|
||||||
{...props}
|
|
||||||
sx={[
|
|
||||||
{
|
|
||||||
py: 1.6,
|
|
||||||
borderRadius: 4,
|
|
||||||
fontSize: '1rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: 'none',
|
|
||||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover': {
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
},
|
|
||||||
'&:active': {
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(Array.isArray(sx) ? sx : [sx]),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Button;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { IconButton, Tooltip } from '@mui/material';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import CheckIcon from '@mui/icons-material/Check';
|
|
||||||
import { copyToClipboard } from '@/utils/clipboard';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制按钮组件属性
|
|
||||||
* @param text 要复制的文本
|
|
||||||
* @param tooltip 提示信息
|
|
||||||
* @param size 按钮大小
|
|
||||||
* @param color 按钮颜色
|
|
||||||
* @param style 自定义样式
|
|
||||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
|
||||||
*/
|
|
||||||
interface CopyButtonProps {
|
|
||||||
text: string;
|
|
||||||
tooltip?: string;
|
|
||||||
size?: 'small' | 'medium' | 'large';
|
|
||||||
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制按钮组件
|
|
||||||
* @param text 要复制的文本
|
|
||||||
* @param tooltip 提示信息
|
|
||||||
* @param size 按钮大小
|
|
||||||
* @param color 按钮颜色
|
|
||||||
* @param style 自定义样式
|
|
||||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
|
||||||
* @returns 复制按钮组件
|
|
||||||
*/
|
|
||||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
|
||||||
text,
|
|
||||||
tooltip = '复制',
|
|
||||||
size = 'small',
|
|
||||||
color = 'primary',
|
|
||||||
style,
|
|
||||||
showMessage,
|
|
||||||
}) => {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (text) {
|
|
||||||
const success = await copyToClipboard(text, showMessage);
|
|
||||||
if (success) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showMessage?.('无内容可复制', { severity: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip title={tooltip}>
|
|
||||||
<IconButton
|
|
||||||
size={size}
|
|
||||||
onClick={handleCopy}
|
|
||||||
style={style}
|
|
||||||
sx={{
|
|
||||||
color: copied ? 'success.main' : color,
|
|
||||||
bgcolor: '#fff',
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: copied
|
|
||||||
? 'success.main'
|
|
||||||
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
|
||||||
? color
|
|
||||||
: `${color}.main`,
|
|
||||||
color: '#fff',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<CheckIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
|
||||||
) : (
|
|
||||||
<ContentCopyIcon fontSize={size === 'small' ? 'small' : 'medium'} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CopyButton;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ToolCard from './ToolCard';
|
|
||||||
|
|
||||||
export interface DashboardCardConfig {
|
|
||||||
/** 卡片标题 */
|
|
||||||
title: string;
|
|
||||||
/** 卡片描述文字 */
|
|
||||||
description: string;
|
|
||||||
/** 主题颜色代码 */
|
|
||||||
colorCode: string;
|
|
||||||
/** 图标组件 */
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardCardProps {
|
|
||||||
/** 卡片配置数据 */
|
|
||||||
config: DashboardCardConfig;
|
|
||||||
/** 点击卡片时的回调函数 */
|
|
||||||
onClick: () => void;
|
|
||||||
/** 卡片右侧的实时预览内容(如时间戳显示) */
|
|
||||||
snapshot?: React.ReactNode;
|
|
||||||
/** 卡片背景色,默认使用主题色 */
|
|
||||||
cardBackgroundColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DashboardCard - 仪表盘卡片组件
|
|
||||||
*
|
|
||||||
* 基于 ToolCard 的封装,专门用于仪表盘页面
|
|
||||||
* 使用 React.memo 避免不必要的重渲染
|
|
||||||
*/
|
|
||||||
const DashboardCard = React.memo(
|
|
||||||
({ config, onClick, snapshot, cardBackgroundColor }: DashboardCardProps) => (
|
|
||||||
<ToolCard
|
|
||||||
title={config.title}
|
|
||||||
description={config.description}
|
|
||||||
colorCode={config.colorCode}
|
|
||||||
icon={config.icon}
|
|
||||||
onClick={onClick}
|
|
||||||
cardBackgroundColor={cardBackgroundColor}
|
|
||||||
snapshot={snapshot}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export default DashboardCard;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
|
||||||
import { Box, Typography, Button, Paper, Container } from '@mui/material';
|
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 错误边界组件:捕获子组件树中的 JavaScript 错误
|
|
||||||
*/
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
|
||||||
public state: State = {
|
|
||||||
hasError: false,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('Uncaught error:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleReset = () => {
|
|
||||||
this.setState({ hasError: false, error: null });
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<Container sx={{ mt: 8 }}>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 4,
|
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'error.light',
|
|
||||||
bgcolor: 'error.shortest',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ErrorOutlineIcon color="error" sx={{ fontSize: 64, mb: 2 }} />
|
|
||||||
<Typography variant="h5" fontWeight={800} gutterBottom color="error.main">
|
|
||||||
糟糕,出了点问题
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
||||||
应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。
|
|
||||||
</Typography>
|
|
||||||
{this.state.error && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mb: 3,
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 2,
|
|
||||||
textAlign: 'left',
|
|
||||||
maxHeight: '200px',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
component="pre"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
color: 'error.dark',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{this.state.error.toString()}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="error"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={this.handleReset}
|
|
||||||
sx={{ borderRadius: 2, fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
刷新应用
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
Collapse,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
SelectChangeEvent,
|
|
||||||
Checkbox,
|
|
||||||
Button,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
|
||||||
import { FieldType } from '@/utils/dummyDataGenerator';
|
|
||||||
|
|
||||||
// 字段数据接口
|
|
||||||
interface FieldData {
|
|
||||||
id: string;
|
|
||||||
fieldType: string;
|
|
||||||
label: string | null;
|
|
||||||
placeholder: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
generatedValue: string;
|
|
||||||
useInvalidData?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字段类型显示名称映射
|
|
||||||
const FIELD_TYPE_NAMES: Record<string, string> = {
|
|
||||||
[FieldType.TEXT]: '文本',
|
|
||||||
[FieldType.EMAIL]: '邮箱',
|
|
||||||
[FieldType.PHONE]: '手机号',
|
|
||||||
[FieldType.NUMBER]: '数字',
|
|
||||||
[FieldType.DATE]: '日期',
|
|
||||||
[FieldType.TEXTarea]: '文本域',
|
|
||||||
[FieldType.RADIO]: '单选框',
|
|
||||||
[FieldType.CHECKBOX]: '复选框',
|
|
||||||
[FieldType.SELECT]: '下拉框',
|
|
||||||
[FieldType.PASSWORD]: '密码',
|
|
||||||
[FieldType.NAME]: '姓名',
|
|
||||||
[FieldType.ID_CARD]: '身份证号',
|
|
||||||
[FieldType.UNKNOWN]: '未知',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FieldListProps {
|
|
||||||
fields: FieldData[];
|
|
||||||
showFields: boolean;
|
|
||||||
onToggleShowFields: () => void;
|
|
||||||
onFieldTypeChange: (fieldId: string, newType: string) => void;
|
|
||||||
onLocateField: (fieldId: string) => void;
|
|
||||||
onHoverField: (fieldId: string | null) => void;
|
|
||||||
onToggleFieldSelection: (fieldId: string) => void;
|
|
||||||
onToggleAllFields: () => void;
|
|
||||||
hoveredFieldId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FieldList: React.FC<FieldListProps> = ({
|
|
||||||
fields,
|
|
||||||
showFields,
|
|
||||||
onToggleShowFields,
|
|
||||||
onFieldTypeChange,
|
|
||||||
onHoverField,
|
|
||||||
onToggleFieldSelection,
|
|
||||||
onToggleAllFields,
|
|
||||||
hoveredFieldId,
|
|
||||||
}) => {
|
|
||||||
if (fields.length === 0) return null;
|
|
||||||
|
|
||||||
const handleTypeChange = (fieldId: string, event: SelectChangeEvent<string>) => {
|
|
||||||
onFieldTypeChange(fieldId, event.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const allSelected = fields.every((f) => f.isSelected);
|
|
||||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Paper elevation={0} sx={{ borderRadius: 4, overflow: 'hidden', mb: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
borderBottom: 1,
|
|
||||||
borderColor: 'divider',
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onClick={onToggleShowFields}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
|
||||||
已识别字段 ({fields.length})
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
bgcolor: selectedCount > 0 ? 'primary.main' : 'grey.300',
|
|
||||||
color: selectedCount > 0 ? 'white' : 'text.secondary',
|
|
||||||
px: 1,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedCount} 已选择
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleAllFields();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{allSelected ? '取消全选' : '全选'}
|
|
||||||
</Button>
|
|
||||||
{showFields ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Collapse in={showFields}>
|
|
||||||
<List dense sx={{ maxHeight: 400, overflow: 'auto' }}>
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<ListItem
|
|
||||||
key={field.id}
|
|
||||||
sx={{
|
|
||||||
py: 1,
|
|
||||||
px: 2,
|
|
||||||
bgcolor: hoveredFieldId === field.id ? '#e3f2fd' : 'transparent',
|
|
||||||
transition: 'background-color 0.2s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => onHoverField(field.id)}
|
|
||||||
onMouseLeave={() => onHoverField(null)}
|
|
||||||
>
|
|
||||||
<ListItemIcon sx={{ minWidth: 40 }}>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={field.isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onToggleFieldSelection(field.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemIcon>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
flex: 1,
|
|
||||||
opacity: field.isSelected ? 1 : 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{field.label || field.name || field.placeholder || `字段 ${index + 1}`}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<FormControl size="small" sx={{ flex: 1, minWidth: 120 }}>
|
|
||||||
<InputLabel>类型</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={field.fieldType}
|
|
||||||
label="类型"
|
|
||||||
onChange={(e) => handleTypeChange(field.id, e)}
|
|
||||||
>
|
|
||||||
{Object.values(FieldType).map((type) => (
|
|
||||||
<MenuItem key={type} value={type}>
|
|
||||||
{FIELD_TYPE_NAMES[type] || type}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{field.placeholder && (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
||||||
占位符: {field.placeholder}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Collapse>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FieldList;
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
/**
|
|
||||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件
|
|
||||||
*
|
|
||||||
* 提供可复用的 Toast 消息提示功能,支持两种使用方式:
|
|
||||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
|
||||||
* 2. 通过 useSnackbarState Hook 使用:自动管理状态
|
|
||||||
*
|
|
||||||
* @module GlobalSnackbar
|
|
||||||
* @version 1.0.0
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 方式一:受控组件
|
|
||||||
* <GlobalSnackbar
|
|
||||||
* message="操作成功"
|
|
||||||
* open={isOpen}
|
|
||||||
* onClose={() => setIsOpen(false)}
|
|
||||||
* severity="success"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // 方式二:Hook 方式
|
|
||||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
|
||||||
* showMessage('Hello!', { severity: 'info' });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { JSX, useState } from 'react';
|
|
||||||
import { Snackbar, Alert, type SxProps, type Theme, alpha, Portal } from '@mui/material';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar 消息严重程度类型
|
|
||||||
* @description 决定 Alert 组件的颜色和图标
|
|
||||||
* - success: 绿色,成功提示
|
|
||||||
* - info: 蓝色,信息提示
|
|
||||||
* - warning: 橙色,警告提示
|
|
||||||
* - error: 红色,错误提示
|
|
||||||
*/
|
|
||||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重新导出 SnackbarProvider 组件
|
|
||||||
* @description 提供 Context 方式的全局 Snackbar 功能
|
|
||||||
*/
|
|
||||||
export { SnackbarProvider } from './SnackbarProvider';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的属性接口
|
|
||||||
* @interface GlobalSnackbarProps
|
|
||||||
*/
|
|
||||||
export interface GlobalSnackbarProps {
|
|
||||||
/** 消息内容,要显示的提示文本 */
|
|
||||||
message: string;
|
|
||||||
/** 是否显示 Snackbar */
|
|
||||||
open: boolean;
|
|
||||||
/** 关闭回调函数 */
|
|
||||||
onClose: () => void;
|
|
||||||
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
|
||||||
severity?: SnackbarSeverity;
|
|
||||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
|
||||||
autoHideDuration?: number;
|
|
||||||
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
|
||||||
anchorOrigin?: {
|
|
||||||
vertical: 'top' | 'bottom';
|
|
||||||
horizontal: 'left' | 'center' | 'right';
|
|
||||||
};
|
|
||||||
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
|
||||||
showAlert?: boolean;
|
|
||||||
/** 是否隐藏 Alert 图标,默认 false */
|
|
||||||
hideIcon?: boolean;
|
|
||||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
|
||||||
alertSx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* showMessage 方法的选项配置
|
|
||||||
* @interface SnackbarOptions
|
|
||||||
*/
|
|
||||||
export interface SnackbarOptions {
|
|
||||||
/** 消息级别:success | info | warning | error */
|
|
||||||
severity?: SnackbarSeverity;
|
|
||||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
|
||||||
autoHideDuration?: number;
|
|
||||||
/** 是否隐藏 Alert 图标 */
|
|
||||||
hideIcon?: boolean;
|
|
||||||
/** 是否使用 Alert 组件包裹 */
|
|
||||||
showAlert?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbarState Hook 的返回值类型
|
|
||||||
* @interface UseSnackbarStateResult
|
|
||||||
*/
|
|
||||||
export interface UseSnackbarStateResult {
|
|
||||||
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
|
||||||
snackbarProps: GlobalSnackbarProps;
|
|
||||||
/** 显示消息的方法 */
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
/** 关闭消息的方法 */
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的默认属性配置
|
|
||||||
* @description 提供类型安全的默认值选择
|
|
||||||
*/
|
|
||||||
const defaultProps: Required<
|
|
||||||
Pick<
|
|
||||||
GlobalSnackbarProps,
|
|
||||||
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
|
||||||
>
|
|
||||||
> = {
|
|
||||||
severity: 'info',
|
|
||||||
autoHideDuration: 2000,
|
|
||||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
|
||||||
showAlert: true,
|
|
||||||
hideIcon: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
|
||||||
* 使用 MUI Snackbar 和 Alert 组件实现消息提示功能。
|
|
||||||
*
|
|
||||||
* @param {GlobalSnackbarProps} props - 组件属性
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - 使用 Portal 组件将 Snackbar 渲染到 body 末尾,避免 z-index 问题
|
|
||||||
* - 默认位置在屏幕底部居中
|
|
||||||
* - 自动设置高 z-index 确保显示在其他内容之上
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 受控模式
|
|
||||||
* const [open, setOpen] = useState(false);
|
|
||||||
* <GlobalSnackbar
|
|
||||||
* message="保存成功"
|
|
||||||
* open={open}
|
|
||||||
* onClose={() => setOpen(false)}
|
|
||||||
* severity="success"
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function GlobalSnackbar({
|
|
||||||
message,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
severity = defaultProps.severity,
|
|
||||||
autoHideDuration = defaultProps.autoHideDuration,
|
|
||||||
showAlert = defaultProps.showAlert,
|
|
||||||
hideIcon = defaultProps.hideIcon,
|
|
||||||
}: GlobalSnackbarProps): JSX.Element {
|
|
||||||
/**
|
|
||||||
* 使用 Portal 将 Snackbar 传送到 DOM 顶层 (body 标签下)
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* Portal 的优势:
|
|
||||||
* - 避免父容器 overflow、z-index 等样式影响
|
|
||||||
* - 确保 Snackbar 始终显示在最顶层
|
|
||||||
* - 避免与其他组件的样式冲突
|
|
||||||
*/
|
|
||||||
return (
|
|
||||||
<Portal>
|
|
||||||
<Snackbar
|
|
||||||
open={open}
|
|
||||||
autoHideDuration={autoHideDuration}
|
|
||||||
onClose={onClose}
|
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
||||||
disableWindowBlurListener
|
|
||||||
sx={{
|
|
||||||
zIndex: 999999,
|
|
||||||
// 确保距离底部的间距,响应式设计适配不同屏幕
|
|
||||||
bottom: { xs: '24px', sm: '24px' },
|
|
||||||
// 固定宽度时使用 transform 实现真正的居中
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
minWidth: '140px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showAlert ? (
|
|
||||||
<Alert
|
|
||||||
severity={severity}
|
|
||||||
variant="filled"
|
|
||||||
icon={hideIcon ? false : undefined}
|
|
||||||
sx={{
|
|
||||||
// 胶囊形状,现代化的设计风格
|
|
||||||
borderRadius: '50px',
|
|
||||||
px: 2.5,
|
|
||||||
py: 0.2,
|
|
||||||
minWidth: '140px',
|
|
||||||
// 居中内容
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
// 粗体小字
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
// 移除默认渐变背景
|
|
||||||
backgroundImage: 'none',
|
|
||||||
// 添加阴影效果,颜色根据 severity 自动匹配主题色
|
|
||||||
boxShadow: (theme: Theme) =>
|
|
||||||
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
|
|
||||||
// 图标样式:白色、稍大
|
|
||||||
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
|
|
||||||
// 消息文字样式:白色、适当内边距
|
|
||||||
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message}
|
|
||||||
</Alert>
|
|
||||||
) : undefined}
|
|
||||||
</Snackbar>
|
|
||||||
</Portal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbarState - 消息提示的 Hook 方式
|
|
||||||
*
|
|
||||||
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
|
||||||
* 适合在组件内部使用,无需额外的状态管理代码。
|
|
||||||
*
|
|
||||||
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
|
||||||
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 自动管理 Snackbar 的显示/隐藏状态
|
|
||||||
* - 支持链式调用 showMessage
|
|
||||||
* - 合并初始选项和调用时选项
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
|
||||||
* severity: 'info',
|
|
||||||
* autoHideDuration: 3000,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const handleSave = () => {
|
|
||||||
* // 业务逻辑...
|
|
||||||
* showMessage('保存成功!', { severity: 'success' });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <>
|
|
||||||
* <button onClick={handleSave}>保存</button>
|
|
||||||
* <GlobalSnackbar {...snackbarProps} />
|
|
||||||
* </>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
|
||||||
// Snackbar 显示状态
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
// 当前显示的消息内容
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
// 消息配置选项
|
|
||||||
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示消息
|
|
||||||
*
|
|
||||||
* @param {string} newMessage - 要显示的消息文本
|
|
||||||
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 合并初始选项和新的调用选项
|
|
||||||
* - 新选项会覆盖初始选项
|
|
||||||
*/
|
|
||||||
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
|
||||||
setMessage(newMessage);
|
|
||||||
setOptions({ ...initialOptions, ...newOptions });
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭消息
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 直接将 open 状态设置为 false
|
|
||||||
*/
|
|
||||||
const closeMessage = () => {
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理 Snackbar 关闭事件
|
|
||||||
*
|
|
||||||
* @param {React.SyntheticEvent | Event} [_event] - 关闭事件
|
|
||||||
* @param {string} [reason] - 关闭原因:timeout | clickaway | escapeKeyDown
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 忽略 clickaway 原因(用户点击其他区域),防止误关闭
|
|
||||||
* - 其他情况调用 closeMessage 关闭
|
|
||||||
*/
|
|
||||||
const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => {
|
|
||||||
if (reason === 'clickaway') return;
|
|
||||||
closeMessage();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 传递给 GlobalSnackbar 组件的属性
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 组合当前状态和选项为完整的组件 props
|
|
||||||
* - onClose 使用 handleClose 包装后的版本
|
|
||||||
*/
|
|
||||||
const snackbarProps: GlobalSnackbarProps = {
|
|
||||||
message,
|
|
||||||
open,
|
|
||||||
onClose: handleClose,
|
|
||||||
severity: options.severity,
|
|
||||||
autoHideDuration: options.autoHideDuration,
|
|
||||||
hideIcon: options.hideIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
snackbarProps,
|
|
||||||
showMessage,
|
|
||||||
closeMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的默认导出
|
|
||||||
* @description 方便使用 `import GlobalSnackbar from './GlobalSnackbar'` 方式导入
|
|
||||||
*/
|
|
||||||
export default GlobalSnackbar;
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { Stack, Typography, Box, alpha, SxProps, Theme } from '@mui/material';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PageHeader 组件属性接口
|
|
||||||
*/
|
|
||||||
export interface PageHeaderProps {
|
|
||||||
/** 要显示的图标组件 */
|
|
||||||
icon: ReactNode;
|
|
||||||
/** 图标的颜色,默认为 '#1976d2'(蓝色) */
|
|
||||||
iconColor?: string;
|
|
||||||
/** 主标题文本 */
|
|
||||||
title: string;
|
|
||||||
/** 副标题文本(可选) */
|
|
||||||
subtitle?: string;
|
|
||||||
/** 在标题右侧显示的徽章/标签组件(可选) */
|
|
||||||
badge?: ReactNode;
|
|
||||||
/** 图标容器的自定义样式 */
|
|
||||||
iconSx?: SxProps<Theme>;
|
|
||||||
/** 标题文本的自定义样式 */
|
|
||||||
titleSx?: SxProps<Theme>;
|
|
||||||
/** 副标题文本的自定义样式 */
|
|
||||||
subtitleSx?: SxProps<Theme>;
|
|
||||||
/** 整个组件的自定义样式 */
|
|
||||||
sx?: SxProps<Theme>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PageHeader - 通用页面标题栏组件
|
|
||||||
*
|
|
||||||
* 用于显示带图标的页面标题,支持自定义颜色、副标题、徽章等功能
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader
|
|
||||||
* icon={<AccessTimeIcon />}
|
|
||||||
* iconColor="#1976d2"
|
|
||||||
* title="时间戳转换"
|
|
||||||
* subtitle="Unix 毫秒数转换与格式化"
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <PageHeader
|
|
||||||
* icon={<StorageIcon />}
|
|
||||||
* iconColor={storageCleanerPageStyles.warningColor}
|
|
||||||
* title="存储清理"
|
|
||||||
* subtitle={domain}
|
|
||||||
* badge={<Badge>已占用 {size}</Badge>}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export default function PageHeader({
|
|
||||||
icon,
|
|
||||||
iconColor = '#1976d2',
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
badge,
|
|
||||||
iconSx,
|
|
||||||
titleSx,
|
|
||||||
subtitleSx,
|
|
||||||
sx,
|
|
||||||
}: PageHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2.5, ...sx }}>
|
|
||||||
{/* 图标容器 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 1,
|
|
||||||
borderRadius: 2.5,
|
|
||||||
bgcolor: alpha(iconColor, 0.1),
|
|
||||||
color: iconColor,
|
|
||||||
display: 'flex',
|
|
||||||
...iconSx,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
{/* 标题行(含徽章) */}
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
fontWeight={900}
|
|
||||||
sx={{ letterSpacing: '-0.5px', lineHeight: 1.2, ...titleSx }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{badge}
|
|
||||||
</Stack>
|
|
||||||
{/* 副标题 */}
|
|
||||||
{subtitle && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ fontWeight: 600, ...subtitleSx }}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Alert,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
CircularProgress,
|
|
||||||
InputAdornment,
|
|
||||||
IconButton,
|
|
||||||
} from '@mui/material';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
|
||||||
|
|
||||||
interface QrCodeToUrlSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QrCodeToUrlSection = ({
|
|
||||||
expanded,
|
|
||||||
onExpandedChange,
|
|
||||||
showMessage,
|
|
||||||
}: QrCodeToUrlSectionProps) => {
|
|
||||||
const [qrCodeFile, setQrCodeFile] = useState<File | null>(null);
|
|
||||||
const [parsedUrl, setParsedUrl] = useState('');
|
|
||||||
const [parseError, setParseError] = useState('');
|
|
||||||
const [parsing, setParsing] = useState(false);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const handleFileChange = useCallback((file: File) => {
|
|
||||||
setQrCodeFile(file);
|
|
||||||
setParseError('');
|
|
||||||
setParsedUrl('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
handleFileChange(e.target.files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
const droppedFile = e.dataTransfer.files?.[0];
|
|
||||||
if (droppedFile) {
|
|
||||||
handleFileChange(droppedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseQrCode = async () => {
|
|
||||||
if (!qrCodeFile) {
|
|
||||||
showMessage('请选择二维码图片', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setParsing(true);
|
|
||||||
setParseError('');
|
|
||||||
setParsedUrl('');
|
|
||||||
|
|
||||||
const result = await parseQrCodeFromFile(qrCodeFile);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setParsedUrl(result.data);
|
|
||||||
showMessage('二维码解析成功', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || '未检测到二维码', {
|
|
||||||
severity: 'error',
|
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析二维码失败:', error);
|
|
||||||
showMessage('解析二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
} finally {
|
|
||||||
setParsing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听粘贴事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = async (e: ClipboardEvent) => {
|
|
||||||
if (!expanded) return;
|
|
||||||
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (items[i].type.startsWith('image/')) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const file = items[i].getAsFile();
|
|
||||||
if (file) {
|
|
||||||
try {
|
|
||||||
handleFileChange(file);
|
|
||||||
showMessage('图片粘贴成功', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('处理粘贴图片失败:', error);
|
|
||||||
showMessage('粘贴图片失败,请重试', { severity: 'error', autoHideDuration: 3000 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('paste', handlePaste);
|
|
||||||
};
|
|
||||||
}, [expanded, showMessage, handleFileChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expanded}
|
|
||||||
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 4,
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
||||||
'&:before': { display: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<LinkIcon color="success" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
二维码转 URL
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: 200,
|
|
||||||
border: '2px dashed',
|
|
||||||
borderColor: dragging
|
|
||||||
? qrCodePageStyles.successColor
|
|
||||||
: qrCodeFile
|
|
||||||
? qrCodePageStyles.successColor
|
|
||||||
: 'grey.200',
|
|
||||||
borderRadius: 3,
|
|
||||||
p: 4,
|
|
||||||
bgcolor: dragging
|
|
||||||
? 'rgba(76, 175, 80, 0.1)'
|
|
||||||
: qrCodeFile
|
|
||||||
? 'rgba(76, 175, 80, 0.05)'
|
|
||||||
: 'grey.50',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: qrCodePageStyles.successColor,
|
|
||||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{
|
|
||||||
display: 'none',
|
|
||||||
}}
|
|
||||||
id="qr-code-upload"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="qr-code-upload"
|
|
||||||
style={{ cursor: 'pointer', textAlign: 'center', width: '100%' }}
|
|
||||||
>
|
|
||||||
{qrCodeFile ? (
|
|
||||||
<Box sx={{ textAlign: 'center', width: '100%', position: 'relative' }}>
|
|
||||||
<Box sx={{ position: 'relative', display: 'inline-block' }}>
|
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(qrCodeFile)}
|
|
||||||
alt="QR Code Preview"
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: 160,
|
|
||||||
borderRadius: 8,
|
|
||||||
objectFit: 'contain',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setQrCodeFile(null);
|
|
||||||
setParsedUrl('');
|
|
||||||
setParseError('');
|
|
||||||
showMessage('图片已清除', {
|
|
||||||
severity: 'success',
|
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
||||||
{qrCodeFile.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
点击更换图片
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
点击、拖拽或粘贴上传二维码图片
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
支持 PNG、JPG、WEBP 格式
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={parsing ? <CircularProgress size={16} color="inherit" /> : <LinkIcon />}
|
|
||||||
onClick={parseQrCode}
|
|
||||||
disabled={parsing}
|
|
||||||
sx={{
|
|
||||||
py: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: qrCodePageStyles.successColor,
|
|
||||||
fontWeight: 700,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: qrCodePageStyles.successDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{parsing ? '解析中...' : '解析二维码'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
mt: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
label="解析结果"
|
|
||||||
value={parsedUrl}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
readOnly: true,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<CopyButton
|
|
||||||
text={parsedUrl}
|
|
||||||
tooltip="复制"
|
|
||||||
size="small"
|
|
||||||
color={qrCodePageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{parseError && (
|
|
||||||
<Alert severity="error" sx={{ borderRadius: 3 }}>
|
|
||||||
{parseError}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QrCodeToUrlSection;
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
IconButton,
|
|
||||||
Paper,
|
|
||||||
Typography,
|
|
||||||
useMediaQuery,
|
|
||||||
useTheme,
|
|
||||||
} from '@mui/material';
|
|
||||||
import ImageIcon from '@mui/icons-material/Image';
|
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import ErrorIcon from '@mui/icons-material/Error';
|
|
||||||
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
|
||||||
import CopyButton from './CopyButton';
|
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
|
||||||
|
|
||||||
interface QrCodeUploaderProps {
|
|
||||||
onQrCodeDetected?: (data: string) => void;
|
|
||||||
supportedFormats?: string[];
|
|
||||||
maxFileSize?: number; // in bytes
|
|
||||||
timeout?: number; // in milliseconds
|
|
||||||
showPreview?: boolean;
|
|
||||||
showProgress?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QrCodeUploader: React.FC<QrCodeUploaderProps> = ({
|
|
||||||
onQrCodeDetected,
|
|
||||||
supportedFormats = ['image/png', 'image/jpeg', 'image/webp'],
|
|
||||||
maxFileSize = 5 * 1024 * 1024, // 5MB
|
|
||||||
timeout = 10000, // 10 seconds
|
|
||||||
showPreview = true,
|
|
||||||
showProgress = true,
|
|
||||||
className,
|
|
||||||
}) => {
|
|
||||||
const { snackbarProps, showMessage } = useSnackbarState({ autoHideDuration: 3000 });
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [preview, setPreview] = useState<string | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [result, setResult] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const uploadAreaRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 清理预览 URL
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (preview) {
|
|
||||||
URL.revokeObjectURL(preview);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [preview]);
|
|
||||||
|
|
||||||
// 处理文件
|
|
||||||
const processFile = useCallback(
|
|
||||||
async (file: File) => {
|
|
||||||
setUploading(true);
|
|
||||||
setProgress(0);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
setProgress((prev) => {
|
|
||||||
if (prev >= 90) {
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
return prev + 10;
|
|
||||||
});
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
const result = await parseQrCodeFromFile(file, timeout);
|
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
setProgress(100);
|
|
||||||
|
|
||||||
if (result.success && result.data) {
|
|
||||||
setResult(result.data);
|
|
||||||
showMessage('二维码解析成功', { severity: 'success' });
|
|
||||||
if (onQrCodeDetected) {
|
|
||||||
onQrCodeDetected(result.data);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(result.error || '未检测到二维码');
|
|
||||||
showMessage(result.error || '未检测到二维码', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : '解析失败');
|
|
||||||
showMessage('解析失败: ' + (err instanceof Error ? err.message : '未知错误'), {
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
setTimeout(() => setProgress(0), 500);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[timeout, showMessage, onQrCodeDetected],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理文件
|
|
||||||
const handleFile = useCallback(
|
|
||||||
(selectedFile: File) => {
|
|
||||||
// 检查文件格式
|
|
||||||
if (!supportedFormats.includes(selectedFile.type)) {
|
|
||||||
setError(
|
|
||||||
`不支持的文件格式。支持的格式: ${supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')}`,
|
|
||||||
);
|
|
||||||
showMessage('不支持的文件格式', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查文件大小
|
|
||||||
if (selectedFile.size > maxFileSize) {
|
|
||||||
const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(1);
|
|
||||||
setError(`文件大小超过限制。最大支持 ${maxSizeMB}MB`);
|
|
||||||
showMessage(`文件大小超过限制,最大支持 ${maxSizeMB}MB`, { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
setFile(selectedFile);
|
|
||||||
|
|
||||||
// 创建预览
|
|
||||||
if (showPreview) {
|
|
||||||
const previewUrl = URL.createObjectURL(selectedFile);
|
|
||||||
setPreview(previewUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始处理
|
|
||||||
processFile(selectedFile).catch(console.error);
|
|
||||||
},
|
|
||||||
[supportedFormats, maxFileSize, showPreview, showMessage, processFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理文件选择
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const selectedFile = e.target.files?.[0];
|
|
||||||
if (selectedFile) {
|
|
||||||
handleFile(selectedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理拖拽事件
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDragging(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragging(false);
|
|
||||||
const droppedFile = e.dataTransfer.files?.[0];
|
|
||||||
if (droppedFile) {
|
|
||||||
handleFile(droppedFile);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 监听粘贴事件
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
|
||||||
const items = e.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
if (items[i].type.startsWith('image/')) {
|
|
||||||
e.preventDefault();
|
|
||||||
const pastedFile = items[i].getAsFile();
|
|
||||||
if (pastedFile) {
|
|
||||||
handleFile(pastedFile);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
|
||||||
return () => document.removeEventListener('paste', handlePaste);
|
|
||||||
}, [handleFile]);
|
|
||||||
|
|
||||||
// 清除文件
|
|
||||||
const handleClear = () => {
|
|
||||||
setFile(null);
|
|
||||||
setPreview(null);
|
|
||||||
setResult(null);
|
|
||||||
setError(null);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className={className}>
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<Paper
|
|
||||||
ref={uploadAreaRef}
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: isMobile ? 3 : 4,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: `2px dashed ${dragging ? 'primary.main' : 'grey.300'}`,
|
|
||||||
bgcolor: dragging ? 'primary.lighter' : 'grey.50',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
textAlign: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={supportedFormats.join(',')}
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!file && !uploading ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<ImageIcon sx={{ fontSize: isMobile ? 36 : 48, color: 'grey.400', mb: 2 }} />
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
点击、拖拽或粘贴上传二维码图片
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
支持 {supportedFormats.map((f) => f.split('/')[1].toUpperCase()).join(', ')} 格式
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
|
||||||
最大文件大小: {(maxFileSize / (1024 * 1024)).toFixed(1)}MB
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : file && showPreview && preview ? (
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<img
|
|
||||||
src={preview}
|
|
||||||
alt="QR Code Preview"
|
|
||||||
style={{
|
|
||||||
maxWidth: '100%',
|
|
||||||
maxHeight: 200,
|
|
||||||
borderRadius: 8,
|
|
||||||
objectFit: 'contain',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleClear();
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -8,
|
|
||||||
right: -8,
|
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.9)',
|
|
||||||
color: 'white',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(211, 47, 47, 0.95)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClearIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
|
||||||
{file.name}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : uploading && showProgress ? (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
|
||||||
<CircularProgress size={48} sx={{ mb: 2 }} />
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
||||||
处理中...
|
|
||||||
</Typography>
|
|
||||||
{progress > 0 && (
|
|
||||||
<Box sx={{ width: '80%', mt: 2 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: 8,
|
|
||||||
bgcolor: 'grey.200',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: '100%',
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
width: `${progress}%`,
|
|
||||||
transition: 'width 0.3s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ mt: 1, display: 'block' }}
|
|
||||||
>
|
|
||||||
{progress}%
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : null}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* 结果展示 */}
|
|
||||||
{(result || error) && (
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
{result && (
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 3,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'success.light',
|
|
||||||
bgcolor: 'success.lighter',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
|
||||||
<CheckCircleIcon sx={{ color: 'success.main', mt: 0.5 }} />
|
|
||||||
<Box sx={{ flex: 1 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
|
||||||
二维码内容
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ position: 'relative' }}>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
pr: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result}
|
|
||||||
</Typography>
|
|
||||||
<CopyButton
|
|
||||||
text={result}
|
|
||||||
tooltip="复制"
|
|
||||||
size="small"
|
|
||||||
color="success"
|
|
||||||
showMessage={showMessage}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert severity="error" sx={{ borderRadius: 4 }} icon={<ErrorIcon />}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default QrCodeUploader;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
export default function RouterContainer() {
|
|
||||||
const { currentPage, isLoaded } = useRouter();
|
|
||||||
|
|
||||||
const animationClass = useMemo(() => {
|
|
||||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
const entryPointType = useMemo(() => {
|
|
||||||
return getEntryPointType();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return <div className="app">Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
|
||||||
const Component = currentFeature ? currentFeature.components[entryPointType] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={currentPage} // Trigger animation on navigation
|
|
||||||
className={animationClass}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
scrollbarGutter: 'stable',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Component && <Component />}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* SnackbarProvider - 全局 Snackbar 消息提示 Provider
|
|
||||||
*
|
|
||||||
* 提供全局的 Toast 消息功能,支持成功、错误、警告、信息四种提示类型。
|
|
||||||
* 通过 React Context 向下传递消息显示方法,子组件可通过 useSnackbar hook 调用。
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 基于 GlobalSnackbar 组件实现,复用其状态管理逻辑
|
|
||||||
* - 使用 MUI Snackbar 组件实现消息提示
|
|
||||||
* - 支持自定义自动隐藏时长
|
|
||||||
* - 消息会显示在页面底部居中位置
|
|
||||||
* - 使用 Portal 将 Snackbar 渲染到 body 末尾,避免 z-index 层级问题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createContext, useContext, type ReactNode } from 'react';
|
|
||||||
import GlobalSnackbar, { useSnackbarState } from './GlobalSnackbar';
|
|
||||||
import type { SnackbarOptions } from './GlobalSnackbar';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar Context 的值类型定义
|
|
||||||
* @interface SnackbarContextValue
|
|
||||||
* @property showMessage - 显示消息的方法
|
|
||||||
* @property closeMessage - 关闭消息的方法
|
|
||||||
*/
|
|
||||||
interface SnackbarContextValue {
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Context,用于在组件树中传递 Snackbar 操作方法
|
|
||||||
* @description
|
|
||||||
* - 初始值为 null,表示未包裹在 Provider 中
|
|
||||||
* - 通过 SnackbarProvider 包裹后提供实际值
|
|
||||||
*/
|
|
||||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件的 props 类型
|
|
||||||
* @interface SnackbarProviderProps
|
|
||||||
* @property children - 子组件
|
|
||||||
* @property initialOptions - 初始配置选项
|
|
||||||
*/
|
|
||||||
interface SnackbarProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
initialOptions?: SnackbarOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbar Hook 的选项配置(与 GlobalSnackbar 的 SnackbarOptions 兼容)
|
|
||||||
* @interface UseSnackbarOptions
|
|
||||||
* @property severity - 消息严重程度
|
|
||||||
* @property autoHideDuration - 默认自动隐藏时长
|
|
||||||
* @property hideIcon - 是否隐藏图标
|
|
||||||
* @property showAlert - 是否使用 Alert 组件
|
|
||||||
*/
|
|
||||||
export type UseSnackbarOptions = SnackbarOptions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
|
||||||
* 提供 showMessage 方法用于显示各种类型的提示消息。
|
|
||||||
*
|
|
||||||
* @param {SnackbarProviderProps} props - 组件属性
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - 使用 useGlobalSnackbar() hook 复用 GlobalSnackbar 的状态管理逻辑
|
|
||||||
* - 通过 Context.Provider 将操作方法传递给子组件
|
|
||||||
* - 渲染 GlobalSnackbar 组件显示实际的 Snackbar UI
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <SnackbarProvider initialOptions={{ autoHideDuration: 3000 }}>
|
|
||||||
* <App />
|
|
||||||
* </SnackbarProvider>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 在子组件中使用
|
|
||||||
* const { showMessage } = useSnackbar();
|
|
||||||
* showMessage('操作成功', { severity: 'success' });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps) {
|
|
||||||
/**
|
|
||||||
* 调用 GlobalSnackbar.useSnackbar() 获取状态管理逻辑
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - snackbarProps: 传递给 GlobalSnackbar 组件的属性
|
|
||||||
* - showMessage: 显示消息的方法
|
|
||||||
* - closeMessage: 关闭消息的方法
|
|
||||||
*/
|
|
||||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 Context.Provider 向下传递 snackbar 操作方法
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 子组件通过 useSnackbar() hook 获取这些方法
|
|
||||||
* - GlobalSnackbar 组件放在 Provider 外部,确保它能渲染到 DOM
|
|
||||||
*/
|
|
||||||
return (
|
|
||||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
|
||||||
{children}
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</SnackbarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
|
||||||
*
|
|
||||||
* @param {UseSnackbarOptions} [_options] - 可选的配置项(保留向后兼容性,不实际使用)
|
|
||||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
|
||||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 这是一个自定义 React Hook,用于在任意子组件中访问 Snackbar 功能。
|
|
||||||
* 必须确保组件被 SnackbarProvider 包裹才能使用。
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* - 由于 Context 限制,useSnackbar 的 options 参数无法动态传递给 Provider
|
|
||||||
* - 如需设置全局初始选项,请在 SnackbarProvider 组件上设置 initialOptions
|
|
||||||
* - 如需为单个消息设置选项,请在 showMessage() 方法中传入
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { showMessage } = useSnackbar();
|
|
||||||
*
|
|
||||||
* const handleSuccess = () => {
|
|
||||||
* showMessage('操作成功!', { severity: 'success', autoHideDuration: 5000 });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <div>
|
|
||||||
* <button onClick={handleSuccess}>成功提示</button>
|
|
||||||
* </div>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useSnackbar(_options?: UseSnackbarOptions): SnackbarContextValue {
|
|
||||||
const context = useContext(SnackbarContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default SnackbarProvider;
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
alpha,
|
|
||||||
} from '@mui/material';
|
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { storageCleanerPageStyles, THEME_COLORS } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
export interface StorageCleanerConfirmProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
options: StorageCleanerOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORAGE_LABELS: Record<keyof StorageCleanerOptions, string> = {
|
|
||||||
localStorage: 'LocalStorage',
|
|
||||||
sessionStorage: 'Session Storage',
|
|
||||||
indexedDB: 'IndexedDB',
|
|
||||||
cookies: 'Cookies',
|
|
||||||
cacheStorage: 'Cache Storage',
|
|
||||||
serviceWorkers: 'Service Workers',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StorageCleanerConfirm({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
options,
|
|
||||||
}: StorageCleanerConfirmProps) {
|
|
||||||
const selectedOptions = Object.entries(options)
|
|
||||||
.filter(([_, value]) => value)
|
|
||||||
.map(([key, _]) => STORAGE_LABELS[key as keyof StorageCleanerOptions] || key);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={onClose}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="xs"
|
|
||||||
slotProps={{
|
|
||||||
paper: {
|
|
||||||
sx: {
|
|
||||||
borderRadius: 6,
|
|
||||||
backgroundImage: 'none',
|
|
||||||
boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`,
|
|
||||||
p: 1.5,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTitle
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
pt: 4,
|
|
||||||
pb: 1,
|
|
||||||
fontWeight: 900,
|
|
||||||
letterSpacing: '-0.5px',
|
|
||||||
fontSize: '1.35rem',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认清理数据?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogContent sx={{ textAlign: 'center', pb: 2 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ mb: 3.5, fontWeight: 500, fontSize: '0.9rem' }}
|
|
||||||
>
|
|
||||||
您将永久删除当前页面的以下选定存储项。
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.2, justifyContent: 'center', mb: 4 }}>
|
|
||||||
{selectedOptions.map((label) => (
|
|
||||||
<Chip
|
|
||||||
key={label}
|
|
||||||
label={label}
|
|
||||||
size="small"
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha(THEME_COLORS.warning, 0.04),
|
|
||||||
fontWeight: 700,
|
|
||||||
color: THEME_COLORS.warning,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(THEME_COLORS.warning, 0.15),
|
|
||||||
borderRadius: 2.5,
|
|
||||||
height: 'auto',
|
|
||||||
'& .MuiChip-label': { px: 1.2, py: 0.6 },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
bgcolor: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
color: THEME_COLORS.error,
|
|
||||||
px: 2,
|
|
||||||
py: 0.8,
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px dashed',
|
|
||||||
borderColor: alpha(THEME_COLORS.error, 0.2),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 800,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span role="img" aria-label="warning">
|
|
||||||
⚠️
|
|
||||||
</span>{' '}
|
|
||||||
此操作不可撤销
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions sx={{ p: 3, pt: 1, gap: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
onClick={onClose}
|
|
||||||
fullWidth
|
|
||||||
sx={{
|
|
||||||
boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
color: 'text.primary',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={onConfirm}
|
|
||||||
fullWidth
|
|
||||||
sx={{
|
|
||||||
bgcolor: storageCleanerPageStyles.warningColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: storageCleanerPageStyles.warningDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认清理
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StorageCleanerConfirm;
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { Box, Typography, Stack } from '@mui/material';
|
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; // Sparkles for AI
|
|
||||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface ToolCardProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
snapshot?: React.ReactNode;
|
|
||||||
colorCode: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
onClick: () => void;
|
|
||||||
hasAI?: boolean;
|
|
||||||
cardBackgroundColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ToolCard({ title, description, snapshot, colorCode, icon, onClick, hasAI, cardBackgroundColor = 'background.paper' }: ToolCardProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
onClick={onClick}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
bgcolor: cardBackgroundColor,
|
|
||||||
borderRadius: 4,
|
|
||||||
p: 2.5,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1.5,
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: colorCode,
|
|
||||||
transform: 'translateY(-4px)',
|
|
||||||
boxShadow: `0 12px 24px -10px ${colorCode}33`, // 20% opacity of colorCode
|
|
||||||
'& .arrow-icon': {
|
|
||||||
transform: 'translateX(4px)',
|
|
||||||
color: colorCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: `${colorCode}11`, // 7% opacity
|
|
||||||
color: colorCode
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
color: 'text.primary',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
{hasAI && <AutoAwesomeIcon sx={{ fontSize: 14, color: '#f5b041' }} />}
|
|
||||||
</Typography>
|
|
||||||
{description && (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<ArrowForwardIosIcon
|
|
||||||
className="arrow-icon"
|
|
||||||
sx={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'grey.300',
|
|
||||||
mt: 0.5,
|
|
||||||
transition: 'all 0.3s ease'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{snapshot && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 'auto',
|
|
||||||
pt: 1.5,
|
|
||||||
borderTop: '1px dashed',
|
|
||||||
borderColor: 'grey.100'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{snapshot}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { Box, IconButton, Typography, Stack, Tooltip } from '@mui/material';
|
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
||||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
|
||||||
|
|
||||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
|
||||||
const { currentPage, goBack } = useRouter();
|
|
||||||
|
|
||||||
const isDetachedMode = useMemo(() => {
|
|
||||||
return new URLSearchParams(window.location.search).get('mode') === 'detached';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDetach = () => {
|
|
||||||
// 弹出脱离窗口 (以独立面板形式打开当前 URL,并标记 mode=detached)
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set('mode', 'detached');
|
|
||||||
|
|
||||||
chrome.windows.create({
|
|
||||||
url: url.toString(),
|
|
||||||
type: 'panel',
|
|
||||||
width: 420,
|
|
||||||
height: 600
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDashboard = currentPage === 'dashboard';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
sx={{
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
zIndex: 1100
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: 40 }}>
|
|
||||||
{!isDashboard && (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={goBack}
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
'&:hover': { bgcolor: 'grey.200' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 800,
|
|
||||||
letterSpacing: '0.5px',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'text.secondary'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Testing Tools
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} sx={{ width: 80, justifyContent: 'flex-end' }}>
|
|
||||||
{!isDetachedMode && (
|
|
||||||
<Tooltip title="独立窗口模式">
|
|
||||||
<IconButton size="small" onClick={handleDetach}>
|
|
||||||
<OpenInNewIcon sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip title="设置">
|
|
||||||
<IconButton size="small" onClick={onOpenOptions}>
|
|
||||||
<SettingsIcon sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Box, TextField, Alert, Stack } from '@mui/material';
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
interface UrlEntryFormProps {
|
|
||||||
onAddEntry: (entry: OpenUrlEntry) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlEntryForm = ({ onAddEntry, showMessage }: UrlEntryFormProps) => {
|
|
||||||
const [newName, setNewName] = useState<string>('');
|
|
||||||
const [newUrl, setNewUrl] = useState<string>('');
|
|
||||||
|
|
||||||
const showMixedContentWarning =
|
|
||||||
newUrl.startsWith('http://') && !newUrl.includes('localhost') && !newUrl.includes('127.0.0.1');
|
|
||||||
|
|
||||||
const isValidUrl = (url: string) => {
|
|
||||||
if (!url.trim()) return false;
|
|
||||||
try {
|
|
||||||
new URL(url);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddEntry = () => {
|
|
||||||
if (!newName.trim()) {
|
|
||||||
showMessage('请输入名称', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isValidUrl(newUrl)) {
|
|
||||||
showMessage('请输入有效的 URL', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onAddEntry({ name: newName.trim(), url: newUrl.trim() });
|
|
||||||
setNewName('');
|
|
||||||
setNewUrl('');
|
|
||||||
showMessage('添加成功', { severity: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
mb: 3,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.02)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="环境名称"
|
|
||||||
placeholder="例如: 本地文档"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
sx={openUrlPageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="目标 URL"
|
|
||||||
placeholder="例如: http://localhost:8000/docs"
|
|
||||||
value={newUrl}
|
|
||||||
onChange={(e) => setNewUrl(e.target.value)}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
sx={openUrlPageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showMixedContentWarning && (
|
|
||||||
<Alert
|
|
||||||
severity="warning"
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
'& .MuiAlert-message': { fontSize: '0.7rem', fontWeight: 600, lineHeight: 1.4 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
混合内容警告:当前 HTTPS 页面无法加载 HTTP 资源。
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleAddEntry}
|
|
||||||
disabled={!newName.trim() || !isValidUrl(newUrl)}
|
|
||||||
fullWidth
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
sx={{
|
|
||||||
bgcolor: openUrlPageStyles.themeColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: openUrlPageStyles.primaryDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
添加快捷方式
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlEntryForm;
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { Fragment } from 'react';
|
|
||||||
import { Box, ListItem, Typography, Stack, Divider, Tooltip, IconButton } from '@mui/material';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
||||||
import { alpha } from '@mui/material/styles';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
interface UrlEntryItemProps {
|
|
||||||
entry: OpenUrlEntry;
|
|
||||||
index: number;
|
|
||||||
isLast: boolean;
|
|
||||||
onDelete: (index: number) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlEntryItem = ({ entry, index, isLast, onDelete, showMessage }: UrlEntryItemProps) => {
|
|
||||||
const handleOpenInSidebar = async (entry: OpenUrlEntry) => {
|
|
||||||
try {
|
|
||||||
// 存储目标 URL
|
|
||||||
await storageUtil.set('openUrl/currentUrl', entry.url);
|
|
||||||
// 直接设置侧边栏的路由,而不是通过 syncNavigation 影响弹窗路由
|
|
||||||
await storageUtil.set('app/sidepanelRoute', 'openUrlViewer');
|
|
||||||
|
|
||||||
const [currentTab] = await chrome.tabs.query({
|
|
||||||
active: true,
|
|
||||||
currentWindow: true,
|
|
||||||
});
|
|
||||||
const tabId = currentTab.id;
|
|
||||||
if (!tabId) {
|
|
||||||
showMessage('无法获取当前标签页', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await chrome.sidePanel.setOptions({
|
|
||||||
tabId,
|
|
||||||
path: 'sidepanel.html',
|
|
||||||
enabled: true,
|
|
||||||
});
|
|
||||||
await chrome.sidePanel.open({ windowId: currentTab.windowId });
|
|
||||||
|
|
||||||
// 仅当在 Popup 中时才关闭窗口,防止在侧边栏内点击预览时导致侧边栏关闭
|
|
||||||
if (window.location.pathname.includes('popup.html')) {
|
|
||||||
window.close();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to open side panel:', error);
|
|
||||||
showMessage(`打开失败: ${(error as Error).message}`, { severity: 'error' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenInNewTab = (entry: OpenUrlEntry) => {
|
|
||||||
chrome.tabs.create({ url: entry.url }).catch(console.error);
|
|
||||||
window.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
onDelete(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<ListItem
|
|
||||||
sx={{
|
|
||||||
px: 2,
|
|
||||||
py: 1.5,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
transition: 'background-color 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800, color: 'text.primary' }} noWrap>
|
|
||||||
{entry.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.secondary"
|
|
||||||
noWrap
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.2,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.url}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="在侧边栏预览">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleOpenInSidebar(entry)}
|
|
||||||
sx={{
|
|
||||||
color: openUrlPageStyles.themeColor,
|
|
||||||
bgcolor: alpha(openUrlPageStyles.themeColor, 0.05),
|
|
||||||
'&:hover': { bgcolor: openUrlPageStyles.themeColor, color: '#fff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VisibilityIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="新标签页打开">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleOpenInNewTab(entry)}
|
|
||||||
sx={{
|
|
||||||
color: 'grey.500',
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
'&:hover': { bgcolor: 'grey.600', color: '#fff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="删除">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleDelete}
|
|
||||||
sx={{
|
|
||||||
color: 'error.main',
|
|
||||||
'&:hover': { color: 'error.dark', bgcolor: alpha('#f44336', 0.05) },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</ListItem>
|
|
||||||
{!isLast && <Divider sx={{ mx: 2, borderColor: 'grey.50' }} />}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlEntryItem;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { Box, List, Typography } from '@mui/material';
|
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
|
||||||
import UrlEntryItem from './UrlEntryItem';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface UrlEntryListProps {
|
|
||||||
entries: OpenUrlEntry[];
|
|
||||||
onDeleteEntry: (index: number) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlEntryList = ({ entries, onDeleteEntry, showMessage }: UrlEntryListProps) => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
textAlign: 'center',
|
|
||||||
py: 4,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px dashed',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LinkIcon sx={{ color: 'grey.300', fontSize: 40, mb: 1 }} />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="text.disabled"
|
|
||||||
sx={{ display: 'block', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
暂无快捷方式,请在上方添加
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
disablePadding
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<UrlEntryItem
|
|
||||||
key={index}
|
|
||||||
entry={entry}
|
|
||||||
index={index}
|
|
||||||
isLast={index === entries.length - 1}
|
|
||||||
onDelete={onDeleteEntry}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlEntryList;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
TextField,
|
|
||||||
Button,
|
|
||||||
Stack,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
CircularProgress,
|
|
||||||
} from '@mui/material';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import qrcode from 'qrcode';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface UrlToQrCodeSectionProps {
|
|
||||||
expanded: boolean;
|
|
||||||
onExpandedChange: (expanded: boolean) => void;
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UrlToQrCodeSection = ({
|
|
||||||
expanded,
|
|
||||||
onExpandedChange,
|
|
||||||
showMessage,
|
|
||||||
}: UrlToQrCodeSectionProps) => {
|
|
||||||
const [urlInput, setUrlInput] = useState('');
|
|
||||||
const [urlError, setUrlError] = useState('');
|
|
||||||
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
|
|
||||||
const [generating, setGenerating] = useState(false);
|
|
||||||
|
|
||||||
const handleUrlInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setUrlInput(e.target.value);
|
|
||||||
setUrlError('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateQrCode = async () => {
|
|
||||||
if (!urlInput) {
|
|
||||||
setUrlError('请输入 URL');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setGenerating(true);
|
|
||||||
setUrlError('');
|
|
||||||
|
|
||||||
let url = urlInput;
|
|
||||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
||||||
url = 'https://' + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = await qrcode.toDataURL(url, {
|
|
||||||
width: 200,
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: qrCodePageStyles.black,
|
|
||||||
light: qrCodePageStyles.white,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setQrCodeDataUrl(dataUrl);
|
|
||||||
showMessage('二维码生成成功', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('生成二维码失败:', error);
|
|
||||||
showMessage('生成二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
} finally {
|
|
||||||
setGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadQrCode = () => {
|
|
||||||
if (!qrCodeDataUrl) return;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = qrCodeDataUrl;
|
|
||||||
link.download = 'qrcode.png';
|
|
||||||
link.click();
|
|
||||||
showMessage('二维码下载成功', { severity: 'success', autoHideDuration: 300 });
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyQrCode = async () => {
|
|
||||||
if (!qrCodeDataUrl) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(qrCodeDataUrl);
|
|
||||||
const blob = await response.blob();
|
|
||||||
|
|
||||||
await navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'image/png': blob,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
showMessage('二维码已复制到剪贴板', { severity: 'success', autoHideDuration: 1000 });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('复制二维码失败:', error);
|
|
||||||
showMessage('复制二维码失败,请重试', { severity: 'error', autoHideDuration: 300 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Accordion
|
|
||||||
expanded={expanded}
|
|
||||||
onChange={(_, isExpanded) => onExpandedChange(isExpanded)}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 4,
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
||||||
'&:before': { display: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ borderBottom: 'none' }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
||||||
<QrCodeIcon color="primary" />
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
|
||||||
URL 转二维码
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<TextField
|
|
||||||
label="输入 URL"
|
|
||||||
placeholder="https://example.com"
|
|
||||||
value={urlInput}
|
|
||||||
onChange={handleUrlInputChange}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
error={!!urlError}
|
|
||||||
helperText={urlError}
|
|
||||||
sx={qrCodePageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : <QrCodeIcon />}
|
|
||||||
onClick={generateQrCode}
|
|
||||||
disabled={generating}
|
|
||||||
sx={{
|
|
||||||
py: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
bgcolor: qrCodePageStyles.successColor,
|
|
||||||
fontWeight: 700,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: qrCodePageStyles.successDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{generating ? '生成中...' : '生成二维码'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: 200,
|
|
||||||
border: '2px dashed',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
borderRadius: 3,
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{qrCodeDataUrl ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
width: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={qrCodeDataUrl}
|
|
||||||
alt="QR Code"
|
|
||||||
style={{ maxWidth: '100%', height: 'auto', display: 'block' }}
|
|
||||||
/>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
startIcon={<DownloadIcon />}
|
|
||||||
onClick={downloadQrCode}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
borderColor: qrCodePageStyles.successColor,
|
|
||||||
color: qrCodePageStyles.successColor,
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: qrCodePageStyles.successDark,
|
|
||||||
bgcolor: 'rgba(76, 175, 80, 0.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
下载二维码
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
startIcon={<ContentCopyIcon />}
|
|
||||||
onClick={copyQrCode}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 2,
|
|
||||||
bgcolor: qrCodePageStyles.successColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: qrCodePageStyles.successDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
复制二维码
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
|
||||||
二维码将显示在这里
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UrlToQrCodeSection;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import Button from '../Button';
|
|
||||||
|
|
||||||
describe('Button 组件', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
|
||||||
it('应使用默认属性渲染', () => {
|
|
||||||
render(<Button>点击我</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /点击我/i });
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染自定义文本', () => {
|
|
||||||
render(<Button>提交</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /提交/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染不同变体', () => {
|
|
||||||
const { rerender } = render(<Button variant="contained">填充</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /填充/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(<Button variant="outlined">描边</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /描边/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(<Button variant="text">文本</Button>);
|
|
||||||
expect(screen.getByRole('button', { name: /文本/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('交互测试', () => {
|
|
||||||
it('点击时应调用 onClick', () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(<Button onClick={handleClick}>点击我</Button>);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /点击我/i }));
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('禁用状态下点击不应调用 onClick', () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(
|
|
||||||
<Button onClick={handleClick} disabled>
|
|
||||||
禁用按钮
|
|
||||||
</Button>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /禁用按钮/i }));
|
|
||||||
expect(handleClick).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('样式测试', () => {
|
|
||||||
it('应应用 fullWidth 属性', () => {
|
|
||||||
render(<Button fullWidth>全宽</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /全宽/i });
|
|
||||||
expect(button).toHaveClass('MuiButton-fullWidth');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('状态测试', () => {
|
|
||||||
it('应渲染加载状态', () => {
|
|
||||||
render(<Button loading>加载中</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /加载中/i });
|
|
||||||
expect(button).toHaveClass('MuiButton-loading');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染为禁用状态', () => {
|
|
||||||
render(<Button disabled>禁用</Button>);
|
|
||||||
const button = screen.getByRole('button', { name: /禁用/i });
|
|
||||||
expect(button).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { render, screen, act, renderHook } from '@testing-library/react';
|
|
||||||
import { GlobalSnackbar, useSnackbarState, type GlobalSnackbarProps } from '../GlobalSnackbar';
|
|
||||||
|
|
||||||
describe('GlobalSnackbar 组件系统', () => {
|
|
||||||
const mockOnClose = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: GlobalSnackbarProps = {
|
|
||||||
message: '测试消息',
|
|
||||||
open: true,
|
|
||||||
onClose: mockOnClose,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GlobalSnackbar UI 渲染', () => {
|
|
||||||
it('应渲染消息内容并由于使用了 Portal 出现在 body 中', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} />);
|
|
||||||
// 因为使用了 Portal,它不在常规 render 的容器内,但在 document 中
|
|
||||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 showAlert 为 true 时应渲染 MUI Alert 样式', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
|
||||||
// 验证是否包含 MUI Alert 的类名
|
|
||||||
const alertElement = document.querySelector('.MuiAlert-root');
|
|
||||||
expect(alertElement).toBeInTheDocument();
|
|
||||||
expect(alertElement).toHaveTextContent('测试消息');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
|
||||||
// MUI Alert 图标通常在 .MuiAlert-icon 中
|
|
||||||
const icon = document.querySelector('.MuiAlert-icon');
|
|
||||||
expect(icon).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应根据 severity 应用不同的样式 (通过检查 style 或 class)', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
|
||||||
const alert = document.querySelector('.MuiAlert-filledError');
|
|
||||||
expect(alert).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useSnackbarState Hook 逻辑', () => {
|
|
||||||
it('应能正确初始化并更新状态', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState({ severity: 'warning' }));
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('新提醒', { severity: 'success' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(true);
|
|
||||||
expect(result.current.snackbarProps.message).toBe('新提醒');
|
|
||||||
expect(result.current.snackbarProps.severity).toBe('success');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closeMessage 应立即关闭 Snackbar', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('测试');
|
|
||||||
});
|
|
||||||
expect(result.current.snackbarProps.open).toBe(true);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.closeMessage();
|
|
||||||
});
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('交互与自动隐藏', () => {
|
|
||||||
it('在 autoHideDuration 结束后应触发 onClose', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} autoHideDuration={3000} />);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(3000);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockOnClose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 reason 为 clickaway 时不应调用 onClose (源码逻辑验证)', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
// 模拟 MUI 的 handleClose 被 clickaway 触发
|
|
||||||
act(() => {
|
|
||||||
result.current.snackbarProps.onClose();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 状态应该保持 open: true
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
// 注意:此处取决于你对 useSnackbarState 的期望。
|
|
||||||
// 源码中 handleClose 拦截了 clickaway,所以 open 不会变为 false。
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import PageHeader, { type PageHeaderProps } from '../PageHeader';
|
|
||||||
|
|
||||||
describe('PageHeader 组件系统', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: PageHeaderProps = {
|
|
||||||
icon: <AccessTimeIcon />,
|
|
||||||
title: '时间戳转换',
|
|
||||||
subtitle: 'Unix 毫秒数转换与格式化',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('PageHeader UI 渲染', () => {
|
|
||||||
it('应渲染页面标题栏&副标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByText('时间戳转换')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Unix 毫秒数转换与格式化')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.getByTestId('AccessTimeIcon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染自定义图标&图标颜色', () => {
|
|
||||||
render(<PageHeader {...defaultProps} icon={<CloseIcon />} iconColor="#FF0000" />);
|
|
||||||
expect(screen.getByTestId('CloseIcon')).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('CloseIcon')).toHaveStyle('color: #FF0000;');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染 badge 组件', () => {
|
|
||||||
const badge = <span data-testid="test-badge">New</span>;
|
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
|
||||||
expect(screen.getByTestId('test-badge')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('New')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染 badge 与 title 并排布局', () => {
|
|
||||||
const badge = <span data-testid="side-badge">v1.0</span>;
|
|
||||||
render(<PageHeader {...defaultProps} badge={badge} />);
|
|
||||||
const title = screen.getByText('时间戳转换');
|
|
||||||
const badgeEl = screen.getByTestId('side-badge');
|
|
||||||
expect(title).toBeInTheDocument();
|
|
||||||
expect(badgeEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PageHeader 条件渲染', () => {
|
|
||||||
it('subtitle 为 undefined 时不应渲染副标题', () => {
|
|
||||||
const { container } = render(<PageHeader icon={<AccessTimeIcon />} title="仅标题" />);
|
|
||||||
const captionElements = container.querySelectorAll('p');
|
|
||||||
expect(captionElements.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('subtitle 为空字符串时不应渲染副标题', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<PageHeader icon={<AccessTimeIcon />} title="标题" subtitle="" />,
|
|
||||||
);
|
|
||||||
const captionElements = container.querySelectorAll('p');
|
|
||||||
expect(captionElements.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('badge 为 undefined 时不应渲染 badge 区域', () => {
|
|
||||||
render(<PageHeader {...defaultProps} />);
|
|
||||||
expect(screen.queryByText('v1.0')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PageHeader 样式扩展', () => {
|
|
||||||
it('iconSx 应作为属性传递给图标容器', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<PageHeader {...defaultProps} iconSx={{ border: '2px solid red' }} />,
|
|
||||||
);
|
|
||||||
const iconContainer = container.querySelector('div');
|
|
||||||
expect(iconContainer).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('titleSx 应作为属性传递给标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} titleSx={{ fontWeight: 'bold' }} />);
|
|
||||||
const titleEl = screen.getByText('时间戳转换');
|
|
||||||
expect(titleEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('subtitleSx 应作为属性传递给副标题', () => {
|
|
||||||
render(<PageHeader {...defaultProps} subtitleSx={{ color: 'red' }} />);
|
|
||||||
const subtitleEl = screen.getByText('Unix 毫秒数转换与格式化');
|
|
||||||
expect(subtitleEl).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sx 应作为属性传递给外层容器', () => {
|
|
||||||
const { container } = render(<PageHeader {...defaultProps} sx={{ mt: 3 }} />);
|
|
||||||
const outerElement = container.firstChild;
|
|
||||||
expect(outerElement).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import ToolCard from '../ToolCard';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
|
|
||||||
describe('ToolCard 组件', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
|
||||||
it('应渲染标题和描述', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="测试工具"
|
|
||||||
description="这是一个测试工具"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('测试工具')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('这是一个测试工具')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('无描述时仅渲染标题', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="仅标题"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染图标', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="带图标"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon data-testid="test-icon" />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('提供快照内容时应渲染快照', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="带快照"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('snapshot')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('未提供快照时不渲染快照区域', () => {
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCard
|
|
||||||
title="无快照"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AI 徽章测试', () => {
|
|
||||||
it('hasAI 为 true 时应渲染 AI 徽章', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="AI 工具"
|
|
||||||
hasAI={true}
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoAwesomeIcon = screen.getByTestId('AutoAwesomeIcon');
|
|
||||||
expect(autoAwesomeIcon).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hasAI 为 false 时不应渲染 AI 徽章', () => {
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="普通工具"
|
|
||||||
hasAI={false}
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoAwesomeIcon = screen.queryByTestId('AutoAwesomeIcon');
|
|
||||||
expect(autoAwesomeIcon).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('交互测试', () => {
|
|
||||||
it('点击时应调用 onClick', () => {
|
|
||||||
const handleClick = vi.fn();
|
|
||||||
render(
|
|
||||||
<ToolCard
|
|
||||||
title="可点击"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={handleClick}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const card = screen.getByText('可点击').closest('.MuiBox-root');
|
|
||||||
if (card) {
|
|
||||||
fireEvent.click(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('样式测试', () => {
|
|
||||||
it('应应用自定义颜色代码', () => {
|
|
||||||
const customColor = '#ff5722';
|
|
||||||
const { container } = render(
|
|
||||||
<ToolCard
|
|
||||||
title="自定义颜色"
|
|
||||||
colorCode={customColor}
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconContainer = container.querySelector('.MuiBox-root > div');
|
|
||||||
expect(iconContainer).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
|
||||||
import TopBar from '../TopBar';
|
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
|
|
||||||
const mockRouterValue = {
|
|
||||||
currentPage: 'dashboard' as PageType,
|
|
||||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
|
||||||
pageOrder: ['timestamp'] as PageType[],
|
|
||||||
isLoaded: true,
|
|
||||||
navigateTo: vi.fn(),
|
|
||||||
navigateLocal: vi.fn(),
|
|
||||||
syncNavigation: vi.fn(),
|
|
||||||
goBack: vi.fn(),
|
|
||||||
setVisiblePages: vi.fn(),
|
|
||||||
setPageOrder: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock('@/providers/RouterProvider', () => ({
|
|
||||||
useRouter: () => mockRouterValue,
|
|
||||||
RouterProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('TopBar 组件', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderWithProvider = (ui: React.ReactElement) => {
|
|
||||||
return render(<RouterProvider>{ui}</RouterProvider>);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
|
||||||
it('应使用默认标题渲染', () => {
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
expect(screen.getByText('Testing Tools')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('不在 dashboard 时应渲染返回按钮', () => {
|
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId('ArrowBackIosNewIcon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('在 dashboard 上不应渲染返回按钮', () => {
|
|
||||||
mockRouterValue.currentPage = 'dashboard';
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
expect(screen.queryByTestId('ArrowBackIosNewIcon')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应渲染设置按钮', () => {
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
expect(screen.getByTestId('SettingsIcon')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('交互测试', () => {
|
|
||||||
it('点击设置按钮时应调用 onOpenOptions', () => {
|
|
||||||
const handleOpenOptions = vi.fn();
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={handleOpenOptions} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('SettingsIcon'));
|
|
||||||
expect(handleOpenOptions).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('点击返回按钮时应调用 goBack', () => {
|
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
|
||||||
renderWithProvider(<TopBar onOpenOptions={vi.fn()} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('ArrowBackIosNewIcon'));
|
|
||||||
expect(mockRouterValue.goBack).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import React, { ReactNode } from 'react';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
|
||||||
|
|
||||||
import DashboardPage from '@/entrypoints/popup/pages/DashboardPage';
|
|
||||||
import TimestampPage from '@/entrypoints/popup/pages/TimestampPage';
|
|
||||||
import StorageCleanerPage from '@/entrypoints/popup/pages/StorageCleanerPage';
|
|
||||||
import OpenUrlPage from '@/entrypoints/popup/pages/OpenUrlPage';
|
|
||||||
import OpenUrlViewerPage from '@/entrypoints/popup/pages/OpenUrlViewerPage';
|
|
||||||
import QrCodePage from '@/entrypoints/popup/pages/QrCodePage';
|
|
||||||
import FormRecognizerPage from '@/entrypoints/popup/pages/FormRecognizerPage';
|
|
||||||
import FormMappingPage from '@/entrypoints/popup/pages/FormMappingPage';
|
|
||||||
import FormFillPage from '@/entrypoints/popup/pages/FormFillPage';
|
|
||||||
|
|
||||||
import { THEME_COLORS } from './pageTheme';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 功能配置接口
|
|
||||||
*
|
|
||||||
* 整合了路由信息和仪表盘卡片元数据,作为功能的单一事实来源
|
|
||||||
*/
|
|
||||||
export interface FeatureConfig {
|
|
||||||
/** 页面类型标识 */
|
|
||||||
key: PageType;
|
|
||||||
/** 功能名称(用于路由标签和卡片标题) */
|
|
||||||
label: string;
|
|
||||||
/** 功能描述(用于仪表盘卡片) */
|
|
||||||
description: string;
|
|
||||||
/** 主题颜色(用于仪表盘卡片) */
|
|
||||||
themeColor?: string;
|
|
||||||
/** 图标组件(用于仪表盘卡片) */
|
|
||||||
icon?: ReactNode;
|
|
||||||
/** 默认是否在仪表盘显示 */
|
|
||||||
defaultVisible: boolean;
|
|
||||||
/** 不同显示模式对应的组件 */
|
|
||||||
components: {
|
|
||||||
/** 弹窗模式组件 */
|
|
||||||
popup: React.ComponentType;
|
|
||||||
/** 侧边栏模式组件 */
|
|
||||||
sidepanel: React.ComponentType;
|
|
||||||
/** 独立窗口模式组件 */
|
|
||||||
detached: React.ComponentType;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FEATURES: FeatureConfig[] = [
|
|
||||||
{
|
|
||||||
key: 'dashboard',
|
|
||||||
label: 'Dashboard',
|
|
||||||
description: '',
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: DashboardPage,
|
|
||||||
sidepanel: DashboardPage,
|
|
||||||
detached: DashboardPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'timestamp',
|
|
||||||
label: '时间戳',
|
|
||||||
description: 'Unix 毫秒数转换与格式化',
|
|
||||||
themeColor: THEME_COLORS.primary,
|
|
||||||
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: TimestampPage,
|
|
||||||
sidepanel: TimestampPage,
|
|
||||||
detached: TimestampPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'storageCleaner',
|
|
||||||
label: '存储清理',
|
|
||||||
description: '清理缓存、Cookies 及本地存储',
|
|
||||||
themeColor: THEME_COLORS.warning,
|
|
||||||
icon: <StorageIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: StorageCleanerPage,
|
|
||||||
sidepanel: StorageCleanerPage,
|
|
||||||
detached: StorageCleanerPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'openUrl',
|
|
||||||
label: 'Open Url',
|
|
||||||
description: '打开当前选中的 URL',
|
|
||||||
themeColor: THEME_COLORS.purple,
|
|
||||||
icon: <LanguageIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: OpenUrlPage,
|
|
||||||
sidepanel: OpenUrlPage,
|
|
||||||
detached: OpenUrlPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'qrCode',
|
|
||||||
label: '二维码工具',
|
|
||||||
description: '生成当前选中的 URL 的二维码',
|
|
||||||
themeColor: THEME_COLORS.success,
|
|
||||||
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: QrCodePage,
|
|
||||||
sidepanel: QrCodePage,
|
|
||||||
detached: QrCodePage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'formMapping',
|
|
||||||
label: '表单映射',
|
|
||||||
description: '智能识别表单指纹,自定义填充逻辑',
|
|
||||||
themeColor: THEME_COLORS.primary,
|
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: FormMappingPage,
|
|
||||||
sidepanel: FormMappingPage,
|
|
||||||
detached: FormMappingPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'formFill',
|
|
||||||
label: '智能填充',
|
|
||||||
description: '根据表单指纹填充表单数据',
|
|
||||||
themeColor: THEME_COLORS.primary,
|
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: FormFillPage,
|
|
||||||
sidepanel: FormFillPage,
|
|
||||||
detached: FormFillPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'formRecognizer',
|
|
||||||
label: '表单识别',
|
|
||||||
description: '智能识别表单指纹',
|
|
||||||
themeColor: THEME_COLORS.primary,
|
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
|
||||||
defaultVisible: true,
|
|
||||||
components: {
|
|
||||||
popup: FormRecognizerPage,
|
|
||||||
sidepanel: FormRecognizerPage,
|
|
||||||
detached: FormRecognizerPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'openUrlViewer',
|
|
||||||
label: '查看',
|
|
||||||
description: '',
|
|
||||||
defaultVisible: false,
|
|
||||||
components: {
|
|
||||||
popup: OpenUrlViewerPage,
|
|
||||||
sidepanel: OpenUrlViewerPage,
|
|
||||||
detached: OpenUrlViewerPage,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
|
||||||
return FEATURES.find((f) => f.key === key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultVisibleFeatureKeys(): PageType[] {
|
|
||||||
return FEATURES.filter((f) => f.defaultVisible).map((f) => f.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllFeatureKeys(): PageType[] {
|
|
||||||
return FEATURES.map((f) => f.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultPageOrder(): PageType[] {
|
|
||||||
return FEATURES.filter((f) => f.key !== 'dashboard' && f.key !== 'openUrlViewer').map(
|
|
||||||
(f) => f.key,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEntryPointType(): 'popup' | 'sidepanel' | 'detached' {
|
|
||||||
const pathname = window.location.pathname;
|
|
||||||
if (pathname.includes('sidepanel')) {
|
|
||||||
return 'sidepanel';
|
|
||||||
}
|
|
||||||
if (new URLSearchParams(window.location.search).get('mode') === 'detached') {
|
|
||||||
return 'detached';
|
|
||||||
}
|
|
||||||
return 'popup';
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import type { Theme } from '@mui/material';
|
|
||||||
import { alpha } from '@mui/material';
|
|
||||||
|
|
||||||
export const DATE_FORMAT = 'YYYY/MM/DD HH:mm:ss';
|
|
||||||
|
|
||||||
export const ZONES = ['Asia/Shanghai', 'America/New_York', 'Europe/London'] as const;
|
|
||||||
|
|
||||||
export type UnitType = 'ms' | 's';
|
|
||||||
export type ZoneType = (typeof ZONES)[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
|
|
||||||
* 所有颜色都经过对比度计算,确保可访问性
|
|
||||||
*/
|
|
||||||
export const THEME_COLORS = {
|
|
||||||
// 主要颜色 - 蓝色系
|
|
||||||
// 主色 #1976d2 在白底对比度 4.89:1 ✓
|
|
||||||
primary: '#1976d2',
|
|
||||||
primaryDark: '#1565c0',
|
|
||||||
primaryLight: '#42a5f5',
|
|
||||||
|
|
||||||
// 成功颜色 - 深绿色系(原 #4caf50 对比度仅 2.88:1,不达标)
|
|
||||||
// 新颜色 #2e7d32 在白底对比度 4.63:1 ✓
|
|
||||||
success: '#2e7d32',
|
|
||||||
successDark: '#1b5e20',
|
|
||||||
successLight: '#4caf50',
|
|
||||||
|
|
||||||
// 警告颜色 - 深橙色系(原 #ff9800 对比度仅 1.61:1,严重不达标)
|
|
||||||
// 新颜色 #e65100 在白底对比度 4.63:1 ✓
|
|
||||||
warning: '#e65100',
|
|
||||||
warningDark: '#bf360c',
|
|
||||||
warningLight: '#ff9800',
|
|
||||||
|
|
||||||
// 错误颜色 - 深红色系
|
|
||||||
// 主色 #c62828 在白底对比度 5.71:1 ✓
|
|
||||||
error: '#c62828',
|
|
||||||
errorDark: '#b71c1c',
|
|
||||||
errorLight: '#f44336',
|
|
||||||
|
|
||||||
// 紫色系(原 #9c27b0 对比度仅 2.23:1,不达标)
|
|
||||||
// 新颜色 #6a1b9a 在白底对比度 4.63:1 ✓
|
|
||||||
purple: '#6a1b9a',
|
|
||||||
purpleDark: '#4a148c',
|
|
||||||
purpleLight: '#9c27b0',
|
|
||||||
|
|
||||||
// 中性色
|
|
||||||
white: '#FFFFFF',
|
|
||||||
black: '#000000',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 语义化的状态颜色别名
|
|
||||||
* 提供直观的状态表示,提高代码可读性
|
|
||||||
*/
|
|
||||||
export const STATUS_COLORS = {
|
|
||||||
success: THEME_COLORS.success,
|
|
||||||
warning: THEME_COLORS.warning,
|
|
||||||
error: THEME_COLORS.error,
|
|
||||||
info: THEME_COLORS.primary,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局样式配置
|
|
||||||
*/
|
|
||||||
export const globalStyles = {
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 时间戳转换页面样式
|
|
||||||
*/
|
|
||||||
export const timestampPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
INPUT_STYLE: {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'& fieldset': { border: 'none' },
|
|
||||||
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
borderColor: 'primary.main',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.primary.main}1a`,
|
|
||||||
},
|
|
||||||
'&.Mui-error': {
|
|
||||||
borderColor: 'error.main',
|
|
||||||
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.error.main}1a`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': {
|
|
||||||
py: 1.4,
|
|
||||||
px: 2,
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cardBg: alpha(THEME_COLORS.primary, 0.04),
|
|
||||||
cardBorder: alpha(THEME_COLORS.primary, 0.1),
|
|
||||||
switcherBg: alpha(THEME_COLORS.primary, 0.08),
|
|
||||||
switcherBorder: alpha(THEME_COLORS.primary, 0.1),
|
|
||||||
mutedText: alpha(THEME_COLORS.primary, 0.4),
|
|
||||||
resultBg: alpha(THEME_COLORS.primary, 0.05),
|
|
||||||
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开 URL 页面样式
|
|
||||||
*/
|
|
||||||
export const openUrlPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.purple,
|
|
||||||
primaryDark: THEME_COLORS.purpleDark,
|
|
||||||
INPUT_STYLE: {
|
|
||||||
'& .MuiOutlinedInput-root': {
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
'&:hover fieldset': {
|
|
||||||
borderColor: 'grey.300',
|
|
||||||
},
|
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
|
||||||
'&.Mui-focused fieldset': {
|
|
||||||
borderColor: THEME_COLORS.purple,
|
|
||||||
},
|
|
||||||
'&.Mui-focused': {
|
|
||||||
bgcolor: '#fff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'& .MuiInputBase-input': {
|
|
||||||
py: '14px',
|
|
||||||
px: 2,
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
},
|
|
||||||
'& .MuiInputLabel-root': {
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&.Mui-focused': { color: THEME_COLORS.purple },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
themeColor: THEME_COLORS.purple,
|
|
||||||
themeBg: alpha(THEME_COLORS.purple, 0.1),
|
|
||||||
buttonBg: alpha(THEME_COLORS.purple, 0.85),
|
|
||||||
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.purple, 0.2)}`,
|
|
||||||
errorColor: THEME_COLORS.error,
|
|
||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查看 URL 页面样式
|
|
||||||
*/
|
|
||||||
export const openUrlViewerPageStyles = {
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 存储清理页面样式
|
|
||||||
*/
|
|
||||||
export const storageCleanerPageStyles = {
|
|
||||||
warningColor: THEME_COLORS.warning,
|
|
||||||
warningDark: THEME_COLORS.warningDark,
|
|
||||||
warningBg: alpha(THEME_COLORS.warning, 0.05),
|
|
||||||
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
|
|
||||||
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
|
||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 二维码工具页面样式
|
|
||||||
* 注意:保留 successColor 和 successDark 以保持向后兼容性
|
|
||||||
*/
|
|
||||||
export const qrCodePageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.success,
|
|
||||||
primaryDark: THEME_COLORS.successDark,
|
|
||||||
successColor: THEME_COLORS.success,
|
|
||||||
successDark: THEME_COLORS.successDark,
|
|
||||||
white: THEME_COLORS.white,
|
|
||||||
black: THEME_COLORS.black,
|
|
||||||
INPUT_STYLE: {},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仪表盘页面样式
|
|
||||||
*/
|
|
||||||
export const dashboardPageStyles = {
|
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
cardBackgroundColor: '#ffffff',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表单识别页面样式
|
|
||||||
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
|
|
||||||
*/
|
|
||||||
export const formRecognizerPageStyles = {
|
|
||||||
primaryColor: '#ff5722',
|
|
||||||
validColor: THEME_COLORS.success,
|
|
||||||
validDark: THEME_COLORS.successDark,
|
|
||||||
invalidColor: THEME_COLORS.warning,
|
|
||||||
invalidDark: THEME_COLORS.warningDark,
|
|
||||||
clearColor: THEME_COLORS.error,
|
|
||||||
clearDark: THEME_COLORS.errorDark,
|
|
||||||
clearBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
buttonStyle: {
|
|
||||||
py: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
fontWeight: 700,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表单映射页面样式
|
|
||||||
*/
|
|
||||||
export const formMappingPageStyles = {
|
|
||||||
secondaryColor: THEME_COLORS.purple,
|
|
||||||
} as const;
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
|
||||||
import { THEME_COLORS } from './pageTheme';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一的 MUI 主题配置
|
|
||||||
* 整合了原有的外部 CSS 样式(滚动条、动画、基础重置)
|
|
||||||
*/
|
|
||||||
const theme = createTheme({
|
|
||||||
palette: {
|
|
||||||
primary: {
|
|
||||||
main: THEME_COLORS.primary,
|
|
||||||
dark: THEME_COLORS.primaryDark,
|
|
||||||
light: THEME_COLORS.primaryLight,
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
main: THEME_COLORS.success,
|
|
||||||
dark: THEME_COLORS.successDark,
|
|
||||||
light: THEME_COLORS.successLight,
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
main: THEME_COLORS.warning,
|
|
||||||
dark: THEME_COLORS.warningDark,
|
|
||||||
light: THEME_COLORS.warningLight,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: THEME_COLORS.error,
|
|
||||||
dark: THEME_COLORS.errorDark,
|
|
||||||
light: THEME_COLORS.errorLight,
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: THEME_COLORS.purple,
|
|
||||||
dark: THEME_COLORS.purpleDark,
|
|
||||||
light: THEME_COLORS.purpleLight,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
fontFamily: [
|
|
||||||
'-apple-system',
|
|
||||||
'BlinkMacSystemFont',
|
|
||||||
'"Segoe UI"',
|
|
||||||
'Roboto',
|
|
||||||
'"Helvetica Neue"',
|
|
||||||
'Arial',
|
|
||||||
'sans-serif',
|
|
||||||
'"Apple Color Emoji"',
|
|
||||||
'"Segoe UI Emoji"',
|
|
||||||
'"Segoe UI Symbol"',
|
|
||||||
].join(','),
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiCssBaseline: {
|
|
||||||
styleOverrides: {
|
|
||||||
':root': {
|
|
||||||
'--sb-width': '6px',
|
|
||||||
'--sb-thumb-color': 'rgba(0, 0, 0, 0.1)',
|
|
||||||
'--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)',
|
|
||||||
'--sb-track-color': 'transparent',
|
|
||||||
},
|
|
||||||
'html, body, #root': {
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
minWidth: '400px',
|
|
||||||
minHeight: '600px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
},
|
|
||||||
// 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理,
|
|
||||||
// 这里提供全局基础,具体尺寸在 App 容器中限制)
|
|
||||||
body: {
|
|
||||||
WebkitFontSmoothing: 'antialiased',
|
|
||||||
MozOsxFontSmoothing: 'grayscale',
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
|
||||||
},
|
|
||||||
'h1, h2, h3, h4, h5, h6': {
|
|
||||||
fontSize: 'inherit',
|
|
||||||
fontWeight: 'inherit',
|
|
||||||
},
|
|
||||||
/* 全局极简滚动条定制 */
|
|
||||||
'*::-webkit-scrollbar': {
|
|
||||||
width: 'var(--sb-width)',
|
|
||||||
},
|
|
||||||
'*::-webkit-scrollbar-track': {
|
|
||||||
background: 'var(--sb-track-color)',
|
|
||||||
},
|
|
||||||
'*::-webkit-scrollbar-thumb': {
|
|
||||||
background: 'var(--sb-thumb-color)',
|
|
||||||
borderRadius: '10px',
|
|
||||||
backgroundClip: 'content-box',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
},
|
|
||||||
'*::-webkit-scrollbar-thumb:hover': {
|
|
||||||
background: 'var(--sb-thumb-hover)',
|
|
||||||
},
|
|
||||||
/* Animations */
|
|
||||||
'@keyframes slideInRight': {
|
|
||||||
from: {
|
|
||||||
transform: 'translateX(30px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'@keyframes fadeIn': {
|
|
||||||
from: {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'.page-transition-enter': {
|
|
||||||
animation: 'slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards',
|
|
||||||
},
|
|
||||||
'.page-transition-dashboard': {
|
|
||||||
animation: 'fadeIn 0.3s ease-out forwards',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default theme;
|
|
||||||
@@ -0,0 +1,871 @@
|
|||||||
|
# Testing Tools — 视觉规范文档
|
||||||
|
|
||||||
|
> **版本**: 1.0.0
|
||||||
|
> **日期**: 2026-05-29
|
||||||
|
> **适用范围**: 所有新页面、新组件、UI 修改
|
||||||
|
> **设计系统**: 基于 [shadcn/ui](https://ui.shadcn.com/) + Tailwind CSS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [设计原则](#1-设计原则)
|
||||||
|
2. [色彩系统](#2-色彩系统)
|
||||||
|
3. [排版规范](#3-排版规范)
|
||||||
|
4. [间距与布局](#4-间距与布局)
|
||||||
|
5. [圆角与阴影](#5-圆角与阴影)
|
||||||
|
6. [组件规范](#6-组件规范)
|
||||||
|
7. [交互与动效](#7-交互与动效)
|
||||||
|
8. [暗色模式](#8-暗色模式)
|
||||||
|
9. [工具色彩标识](#9-工具色彩标识)
|
||||||
|
10. [代码规范](#10-代码规范)
|
||||||
|
11. [反模式清单](#11-反模式清单)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 设计原则
|
||||||
|
|
||||||
|
### 1.1 核心定位
|
||||||
|
|
||||||
|
Testing Tools 是一款**浏览器扩展开发者工具集**,视觉风格遵循:
|
||||||
|
|
||||||
|
- **专业克制** — 低饱和度色彩,避免视觉噪音
|
||||||
|
- **信息密度优先** — 紧凑布局,在 400×600px 的 popup 空间内高效展示
|
||||||
|
- **开发者友好** — 等宽字体用于代码/数据,清晰的信息层级
|
||||||
|
- **一致性至上** — 所有页面、组件遵循同一套视觉语言
|
||||||
|
|
||||||
|
### 1.2 设计关键词
|
||||||
|
|
||||||
|
```
|
||||||
|
简洁 · 现代 · 功能导向 · 低对比度 · 微圆角 · 微妙阴影
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 与 shadcn/ui 的关系
|
||||||
|
|
||||||
|
本项目以 shadcn/ui 为底座,所有基础组件(Button、Input、Select 等)均来自或对齐 shadcn/ui 的默认样式。业务组件在此基础上扩展,**不得破坏底层设计语言的统一性**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 色彩系统
|
||||||
|
|
||||||
|
### 2.1 CSS 变量定义
|
||||||
|
|
||||||
|
所有色彩通过 CSS 自定义属性(HSL 格式)管理,定义于 `src/index.css`:
|
||||||
|
|
||||||
|
#### 亮色模式 (`:root`)
|
||||||
|
|
||||||
|
| 变量名 | HSL 值 | 用途 | 近似色 |
|
||||||
|
| -------------------------- | ------------------- | ------------- | --------- |
|
||||||
|
| `--background` | `0 0% 100%` | 页面背景 | `#ffffff` |
|
||||||
|
| `--foreground` | `222.2 84% 4.9%` | 主文字 | `#020617` |
|
||||||
|
| `--card` | `0 0% 100%` | 卡片背景 | `#ffffff` |
|
||||||
|
| `--card-foreground` | `222.2 84% 4.9%` | 卡片文字 | `#020617` |
|
||||||
|
| `--popover` | `0 0% 100%` | 浮层背景 | `#ffffff` |
|
||||||
|
| `--popover-foreground` | `222.2 84% 4.9%` | 浮层文字 | `#020617` |
|
||||||
|
| `--primary` | `222.2 47.4% 11.2%` | 主按钮/强调 | `#0f172a` |
|
||||||
|
| `--primary-foreground` | `210 40% 98%` | 主按钮文字 | `#f8fafc` |
|
||||||
|
| `--secondary` | `210 40% 96.1%` | 次级背景 | `#f1f5f9` |
|
||||||
|
| `--secondary-foreground` | `222.2 47.4% 11.2%` | 次级文字 | `#0f172a` |
|
||||||
|
| `--muted` | `210 40% 96.1%` | 静音/禁用背景 | `#f1f5f9` |
|
||||||
|
| `--muted-foreground` | `215.4 16.3% 46.9%` | 次要文字 | `#64748b` |
|
||||||
|
| `--accent` | `210 40% 96.1%` | 悬停高亮 | `#f1f5f9` |
|
||||||
|
| `--accent-foreground` | `222.2 47.4% 11.2%` | 悬停文字 | `#0f172a` |
|
||||||
|
| `--destructive` | `0 84.2% 60.2%` | 错误/删除 | `#ef4444` |
|
||||||
|
| `--destructive-foreground` | `210 40% 98%` | 错误文字 | `#f8fafc` |
|
||||||
|
| `--border` | `214.3 31.8% 91.4%` | 边框 | `#e2e8f0` |
|
||||||
|
| `--input` | `214.3 31.8% 91.4%` | 输入框边框 | `#e2e8f0` |
|
||||||
|
| `--ring` | `222.2 84% 4.9%` | 焦点环 | `#020617` |
|
||||||
|
| `--radius` | `0.5rem` | 全局圆角 | `8px` |
|
||||||
|
|
||||||
|
#### 暗色模式 (`.dark`)
|
||||||
|
|
||||||
|
暗色模式下所有变量自动反转,保持对比度关系:
|
||||||
|
|
||||||
|
| 变量名 | HSL 值 | 近似色 |
|
||||||
|
| ---------------------- | ------------------- | --------- |
|
||||||
|
| `--background` | `222.2 84% 4.9%` | `#020617` |
|
||||||
|
| `--foreground` | `210 40% 98%` | `#f8fafc` |
|
||||||
|
| `--primary` | `210 40% 98%` | `#f8fafc` |
|
||||||
|
| `--primary-foreground` | `222.2 47.4% 11.2%` | `#0f172a` |
|
||||||
|
| `--secondary` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||||
|
| `--muted` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||||
|
| `--border` | `217.2 32.6% 17.5%` | `#1e293b` |
|
||||||
|
|
||||||
|
### 2.2 使用规范
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 正确:使用 CSS 变量
|
||||||
|
<div className="bg-background text-foreground border-border">
|
||||||
|
|
||||||
|
// ✅ 正确:使用语义化色彩名
|
||||||
|
<Button className="bg-primary text-primary-foreground">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
|
||||||
|
// ❌ 错误:硬编码颜色值
|
||||||
|
<div className="bg-white text-black">
|
||||||
|
<div className="bg-[#f1f5f9]">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 语义化色彩使用场景
|
||||||
|
|
||||||
|
| 色彩 | 场景 |
|
||||||
|
| -------------------------- | -------------------------------- |
|
||||||
|
| `background` | 页面根背景 |
|
||||||
|
| `foreground` | 主标题、正文 |
|
||||||
|
| `muted-foreground` | 描述文字、占位符、次级标签 |
|
||||||
|
| `border` | 卡片边框、分割线、输入框边框 |
|
||||||
|
| `card` + `card-foreground` | 卡片容器及其内容 |
|
||||||
|
| `primary` | 主按钮、选中状态、关键操作 |
|
||||||
|
| `secondary` | 次级按钮、工具栏背景、标签页背景 |
|
||||||
|
| `destructive` | 错误提示、删除操作、验证失败 |
|
||||||
|
| `accent` | 悬停背景、下拉选中项 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 排版规范
|
||||||
|
|
||||||
|
### 3.1 字体栈
|
||||||
|
|
||||||
|
项目使用系统默认字体栈(Tailwind 默认),**不引入自定义字体**。
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Tailwind 默认 sans-serif */
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
|
||||||
|
/* 等宽字体用于代码/数据 */
|
||||||
|
font-family:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 字号层级
|
||||||
|
|
||||||
|
| 层级 | 类名 | 大小 | 字重 | 用途 |
|
||||||
|
| --------- | ------------------------------------------------ | ---- | ------- | ---------------------- |
|
||||||
|
| 页面标题 | `text-base font-bold` | 16px | 700 | 页面主标题(极少使用) |
|
||||||
|
| 卡片标题 | `text-sm font-bold tracking-tight` | 14px | 700 | 卡片/区块标题 |
|
||||||
|
| 正文 | `text-sm` | 14px | 400 | 普通正文 |
|
||||||
|
| 次级文字 | `text-xs` | 12px | 400/500 | 描述、标签 |
|
||||||
|
| 微标签 | `text-[10px] font-bold uppercase tracking-wider` | 10px | 700 | 区域标签、分类标题 |
|
||||||
|
| 数据/代码 | `font-mono text-sm` | 14px | 400 | 时间戳、JSON、代码 |
|
||||||
|
|
||||||
|
### 3.3 排版模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 区域标签(Section Label)— 最常用
|
||||||
|
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
|
||||||
|
输出结果
|
||||||
|
</span>
|
||||||
|
|
||||||
|
// 卡片标题
|
||||||
|
<h4 className="font-bold text-sm tracking-tight text-foreground leading-snug">
|
||||||
|
标题文字
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
// 描述文字
|
||||||
|
<p className="text-[11px] font-medium text-muted-foreground/90 leading-normal">
|
||||||
|
描述内容
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// 数据展示
|
||||||
|
<span className="font-mono font-bold text-foreground text-sm tracking-tight tabular-nums">
|
||||||
|
1716950400000
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 行高与字间距
|
||||||
|
|
||||||
|
| 属性 | 值 | 场景 |
|
||||||
|
| ----------------- | -------- | ------------------ |
|
||||||
|
| `leading-none` | 1 | 单行数据、紧凑布局 |
|
||||||
|
| `leading-snug` | 1.375 | 标题、短文本 |
|
||||||
|
| `leading-relaxed` | 1.625 | 长文本、代码块 |
|
||||||
|
| `tracking-tight` | -0.025em | 标题、数据 |
|
||||||
|
| `tracking-wider` | 0.05em | 大写标签 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 间距与布局
|
||||||
|
|
||||||
|
### 4.1 容器尺寸
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Popup 模式(默认)
|
||||||
|
<div className="w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px]">
|
||||||
|
|
||||||
|
// Tab 模式(全屏自适应)
|
||||||
|
<div className="sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 间距节奏
|
||||||
|
|
||||||
|
| Token | 值 | 使用场景 |
|
||||||
|
| --------------- | ----------- | ------------------ |
|
||||||
|
| `p-3` / `p-3.5` | 12px / 14px | 页面内边距(紧凑) |
|
||||||
|
| `p-4` | 16px | 标准页面内边距 |
|
||||||
|
| `p-5` | 20px | 卡片内部填充 |
|
||||||
|
| `gap-2` | 8px | 紧凑元素间距 |
|
||||||
|
| `gap-3` | 12px | 标准元素间距 |
|
||||||
|
| `gap-4` | 16px | 区块间距 |
|
||||||
|
| `gap-6` | 24px | 大区块间距 |
|
||||||
|
|
||||||
|
### 4.3 布局模式
|
||||||
|
|
||||||
|
#### 页面布局
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准页面结构
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">{/* 页面内容 */}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 卡片布局
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准卡片
|
||||||
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||||
|
{/* 卡片内容 */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// 可聚焦卡片(含焦点环)
|
||||||
|
<div className="border border-border rounded-xl bg-card ... focus-within:ring-1 focus-within:ring-ring focus-within:border-ring">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 双栏网格
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 响应式双栏
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 工具卡片网格(Dashboard)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Dashboard 紧凑网格
|
||||||
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 圆角与阴影
|
||||||
|
|
||||||
|
### 5.1 圆角体系
|
||||||
|
|
||||||
|
| Token | 值 | 使用元素 |
|
||||||
|
| -------------- | ------ | ---------------------- |
|
||||||
|
| `rounded-sm` | 2px | Checkbox、小标签 |
|
||||||
|
| `rounded-md` | 6px | 按钮、输入框、Select |
|
||||||
|
| `rounded-lg` | 8px | 搜索框、小卡片 |
|
||||||
|
| `rounded-xl` | 12px | 大卡片、面板、图标容器 |
|
||||||
|
| `rounded-full` | 9999px | 标签、Avatar |
|
||||||
|
|
||||||
|
### 5.2 阴影体系
|
||||||
|
|
||||||
|
| 级别 | 类名 | 用途 |
|
||||||
|
| ---- | --------------------- | ---------------------- |
|
||||||
|
| 无 | — | 静态元素 |
|
||||||
|
| 低 | `shadow-sm` | 卡片、输入框、按钮 |
|
||||||
|
| 中 | `shadow-lg` | 下拉菜单、浮层、Dialog |
|
||||||
|
| 动态 | 自定义 `shadow-[...]` | 卡片悬停时的彩色阴影 |
|
||||||
|
|
||||||
|
### 5.3 彩色阴影规范(工具卡片专用)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 工具卡片悬停阴影 — 必须使用 rgba 格式配合 CSS 变量
|
||||||
|
className="hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||||
|
dark:hover:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 组件规范
|
||||||
|
|
||||||
|
### 6.1 Button
|
||||||
|
|
||||||
|
来源:`src/components/ui/button.tsx`
|
||||||
|
|
||||||
|
#### 变体
|
||||||
|
|
||||||
|
| 变体 | 类名 | 场景 |
|
||||||
|
| ------------- | -------------------------------------------- | ------------------ |
|
||||||
|
| `default` | `bg-primary text-primary-foreground` | 主操作 |
|
||||||
|
| `destructive` | `bg-destructive text-destructive-foreground` | 删除、危险操作 |
|
||||||
|
| `outline` | `border border-input bg-background` | 次级操作、取消 |
|
||||||
|
| `secondary` | `bg-secondary text-secondary-foreground` | 次要操作 |
|
||||||
|
| `ghost` | 仅悬停背景 | 图标按钮、低优先级 |
|
||||||
|
| `link` | 下划线文字 | 跳转链接 |
|
||||||
|
|
||||||
|
#### 尺寸
|
||||||
|
|
||||||
|
| 尺寸 | 高度 | 内边距 | 场景 |
|
||||||
|
| --------- | ------- | ----------- | -------- |
|
||||||
|
| `default` | 40px | `px-4 py-2` | 标准按钮 |
|
||||||
|
| `sm` | 36px | `px-3` | 紧凑按钮 |
|
||||||
|
| `lg` | 44px | `px-8` | 突出按钮 |
|
||||||
|
| `icon` | 40×40px | — | 图标按钮 |
|
||||||
|
|
||||||
|
#### 使用示例
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 主操作
|
||||||
|
<Button>确认</Button>
|
||||||
|
|
||||||
|
// 图标按钮
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
// 危险操作
|
||||||
|
<Button variant="destructive" size="sm">删除</Button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Input
|
||||||
|
|
||||||
|
来源:`src/components/ui/input.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准输入框
|
||||||
|
<Input
|
||||||
|
className="font-mono font-semibold h-10 shadow-sm placeholder:text-muted-foreground/60 focus:bg-background"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
<Input
|
||||||
|
className="border-destructive focus-visible:ring-destructive"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 高度统一为 `h-10`(40px)
|
||||||
|
- 等宽字体用于数据输入
|
||||||
|
- 占位符使用 `text-muted-foreground/60`
|
||||||
|
- 错误时边框变红并调整焦点环
|
||||||
|
|
||||||
|
### 6.3 SwitchButtonGroup
|
||||||
|
|
||||||
|
来源:`src/components/ui/switch.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 分段控制器
|
||||||
|
<SwitchButtonGroup
|
||||||
|
value={mode}
|
||||||
|
options={[
|
||||||
|
{ value: 'ts2dt', label: '转日期' },
|
||||||
|
{ value: 'dt2ts', label: '转时间戳' },
|
||||||
|
]}
|
||||||
|
onChange={setMode}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 容器:`rounded-lg bg-muted p-1`
|
||||||
|
- 选中项:`bg-background text-foreground shadow-sm font-semibold`
|
||||||
|
- 未选中项:`hover:bg-background/50 hover:text-foreground/80`
|
||||||
|
- 尺寸:`small`(32px)用于工具页,`medium`(36px)标准
|
||||||
|
|
||||||
|
### 6.4 Card(工具卡片)
|
||||||
|
|
||||||
|
来源:`src/pages/Dashboard/ToolCard.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准工具卡片结构
|
||||||
|
<div className="group relative rounded-xl border border-border/70 bg-card p-4 h-auto flex flex-col gap-3 shadow-sm">
|
||||||
|
{/* 上半部分:图标 + 标题 + 箭头 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
{/* 图标容器 */}
|
||||||
|
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-[rgba(var(--tool-color),0.08)] text-[rgb(var(--tool-color))]">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
{/* 文字 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-sm">标题</h4>
|
||||||
|
<p className="text-[11px] text-muted-foreground/90">描述</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{/* 下半部分:预览区(可选) */}
|
||||||
|
<div className="mt-1 pt-3 border-t border-dashed border-border/80">{snapshot}</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 TextInputArea
|
||||||
|
|
||||||
|
来源:`src/components/TextInputArea.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 多行文本输入区
|
||||||
|
<TextInputArea
|
||||||
|
value={input}
|
||||||
|
onChange={setInput}
|
||||||
|
placeholder="输入内容..."
|
||||||
|
showCount={true}
|
||||||
|
showClear={true}
|
||||||
|
allowCopy={true}
|
||||||
|
minRows={6}
|
||||||
|
maxRows={12}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 外容器:`rounded-md border border-input bg-background shadow-sm`
|
||||||
|
- 焦点状态:`focus-within:ring-1 focus-within:ring-ring`
|
||||||
|
- 错误状态:`border-destructive focus-within:ring-destructive`
|
||||||
|
- 底部工具栏:`h-10 bg-muted/30 border-t border-border/50`
|
||||||
|
- 字体:`font-mono text-sm`
|
||||||
|
|
||||||
|
### 6.6 Dialog
|
||||||
|
|
||||||
|
来源:`src/components/ui/dialog.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 对话框内容
|
||||||
|
<DialogContent className="sm:rounded-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>标题</DialogTitle>
|
||||||
|
<DialogDescription>描述文字</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{/* 内容 */}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline">取消</Button>
|
||||||
|
<Button>确认</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 Select
|
||||||
|
|
||||||
|
来源:`src/components/ui/select.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select>
|
||||||
|
<SelectTrigger className="h-9 shadow-sm bg-background">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-64">
|
||||||
|
<SelectItem className="text-xs font-semibold focus:bg-accent cursor-pointer">选项</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8 Checkbox
|
||||||
|
|
||||||
|
来源:`src/components/ui/checkbox.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准复选框
|
||||||
|
<Checkbox className="h-4 w-4 rounded-sm border-primary data-[state=checked]:bg-primary" />
|
||||||
|
|
||||||
|
// 小型复选框(工具栏内)
|
||||||
|
<Checkbox className="h-3.5 w-3.5 rounded border-input data-[state=checked]:bg-primary shadow-sm" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.9 Badge
|
||||||
|
|
||||||
|
来源:`src/components/ui/badge.tsx`
|
||||||
|
|
||||||
|
| 变体 | 场景 |
|
||||||
|
| ------------- | ------------------ |
|
||||||
|
| `default` | 状态标签、分类 |
|
||||||
|
| `secondary` | 次要标签 |
|
||||||
|
| `destructive` | 错误标签 |
|
||||||
|
| `outline` | 可点击标签、筛选器 |
|
||||||
|
|
||||||
|
### 6.10 CopyButton
|
||||||
|
|
||||||
|
来源:`src/components/CopyButton.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准复制按钮
|
||||||
|
<CopyButton text={content} />
|
||||||
|
|
||||||
|
// 小型复制按钮
|
||||||
|
<CopyButton text={content} size="sm" className="h-7 w-7 rounded-md border" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**规范要点**:
|
||||||
|
|
||||||
|
- 默认 `variant="ghost" size="icon"`
|
||||||
|
- 复制成功后变为绿色背景 + 对勾图标
|
||||||
|
- 使用 `sonner` toast 提示复制结果
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 交互与动效
|
||||||
|
|
||||||
|
### 7.1 过渡规范
|
||||||
|
|
||||||
|
| 属性 | 值 | 场景 |
|
||||||
|
| ------------------- | ---------- | ----------------------------- |
|
||||||
|
| `transition-colors` | 150ms ease | 色彩变化(悬停、焦点) |
|
||||||
|
| `transition-all` | 150ms ease | 综合变化(SwitchButtonGroup) |
|
||||||
|
| `duration-200` | 200ms | 复制按钮状态切换 |
|
||||||
|
|
||||||
|
### 7.2 焦点状态
|
||||||
|
|
||||||
|
所有可交互元素必须有可见的焦点指示器:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 标准焦点环
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
|
||||||
|
// 紧凑焦点环(图标按钮)
|
||||||
|
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring
|
||||||
|
|
||||||
|
// 输入框焦点
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 悬停状态
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 按钮悬停
|
||||||
|
hover:bg-accent hover:text-accent-foreground
|
||||||
|
|
||||||
|
// 卡片悬停
|
||||||
|
hover:bg-muted/30 hover:border-[rgba(var(--tool-color),0.45)]
|
||||||
|
|
||||||
|
// 链接/文字悬停
|
||||||
|
hover:text-foreground hover:underline
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 动画规范
|
||||||
|
|
||||||
|
使用 `tailwindcss-animate` 提供的动画:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 淡入
|
||||||
|
animate-in fade-in duration-150
|
||||||
|
|
||||||
|
// 淡入 + 缩放(SwitchButtonGroup 选中项)
|
||||||
|
animate-in fade-in-50 zoom-in-95 duration-150
|
||||||
|
|
||||||
|
// 从顶部滑入(下拉菜单)
|
||||||
|
animate-in fade-in slide-in-from-top-2 duration-150
|
||||||
|
|
||||||
|
// 错误提示出现
|
||||||
|
animate-in fade-in slide-in-from-top-1 duration-150
|
||||||
|
|
||||||
|
// 骨架屏脉冲
|
||||||
|
animate-pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 禁用状态
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 统一禁用样式
|
||||||
|
disabled:pointer-events-none disabled:opacity-50
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 暗色模式
|
||||||
|
|
||||||
|
### 8.1 实现方式
|
||||||
|
|
||||||
|
通过 `darkMode: 'class'`(Tailwind 配置)+ `.dark` 类切换:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ThemeModeProvider 自动处理
|
||||||
|
document.documentElement.classList.toggle('dark', resolvedMode === 'dark');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 暗色模式下的特殊处理
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 彩色阴影增强(暗色模式下阴影需要更高透明度)
|
||||||
|
shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||||
|
dark:shadow-[0_8px_30px_-10px_rgba(var(--tool-color),0.25)]
|
||||||
|
|
||||||
|
// 图标容器背景增强
|
||||||
|
bg-[rgba(var(--tool-color),0.08)]
|
||||||
|
dark:bg-[rgba(var(--tool-color),0.12)]
|
||||||
|
|
||||||
|
// 成功状态文字调整
|
||||||
|
text-emerald-600 dark:text-emerald-400
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 暗色模式色彩映射原则
|
||||||
|
|
||||||
|
| 亮色 | 暗色 | 说明 |
|
||||||
|
| ---------------- | ---------------- | ------------------------------- |
|
||||||
|
| 纯白背景 | 深蓝黑背景 | 避免纯黑 `#000`,使用 `#020617` |
|
||||||
|
| 浅灰背景 | 深灰背景 | 保持层次关系 |
|
||||||
|
| 深文字 | 浅文字 | 反转对比度 |
|
||||||
|
| 彩色阴影低透明度 | 彩色阴影高透明度 | 暗色需要更强视觉反馈 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 工具色彩标识
|
||||||
|
|
||||||
|
### 9.1 色板定义
|
||||||
|
|
||||||
|
每个工具分配一个主题色,定义于 `src/config/features.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PALETTE_COLORS: Record<PaletteColorKey, string> = {
|
||||||
|
primary: '13, 148, 136', // teal (#0d9488)
|
||||||
|
success: '22, 163, 74', // green (#16a34a)
|
||||||
|
warning: '217, 119, 6', // amber (#d97706)
|
||||||
|
error: '220, 38, 38', // red (#dc2626)
|
||||||
|
secondary: '147, 51, 232', // purple (#9333e8)
|
||||||
|
info: '37, 99, 235', // blue (#2563eb)
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 工具色彩分配
|
||||||
|
|
||||||
|
| 工具 | 色彩键 | 色值 |
|
||||||
|
| ----------- | ----------- | ------ |
|
||||||
|
| 时间戳转换 | `primary` | Teal |
|
||||||
|
| 存储清理 | `warning` | Amber |
|
||||||
|
| 二维码工具 | `success` | Green |
|
||||||
|
| 文本统计 | `secondary` | Purple |
|
||||||
|
| JWT 解析 | `info` | Blue |
|
||||||
|
| JSON 对比 | `primary` | Teal |
|
||||||
|
| Base64 转换 | `info` | Blue |
|
||||||
|
| 右键还原 | `success` | Green |
|
||||||
|
|
||||||
|
### 9.3 工具色彩使用规范
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. 通过 style 注入 CSS 变量
|
||||||
|
<div style={{ ['--tool-color' as string]: rgbValues }}>
|
||||||
|
|
||||||
|
// 2. 图标容器背景(低透明度)
|
||||||
|
bg-[rgba(var(--tool-color),0.08)]
|
||||||
|
dark:bg-[rgba(var(--tool-color),0.12)]
|
||||||
|
|
||||||
|
// 3. 图标颜色
|
||||||
|
text-[rgb(var(--tool-color))]
|
||||||
|
|
||||||
|
// 4. 悬停边框
|
||||||
|
hover:border-[rgba(var(--tool-color),0.45)]
|
||||||
|
|
||||||
|
// 5. 悬停阴影
|
||||||
|
hover:shadow-[0_8px_24px_-8px_rgba(var(--tool-color),0.14)]
|
||||||
|
|
||||||
|
// 6. 箭头悬停色
|
||||||
|
group-hover:text-[rgb(var(--tool-color))]
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:工具色彩仅用于**标识和装饰**,不得用于功能性色彩(如成功/错误状态)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 代码规范
|
||||||
|
|
||||||
|
### 10.1 Tailwind 类名组织顺序
|
||||||
|
|
||||||
|
使用 `cn()` 工具函数(`clsx` + `tailwind-merge`)组合类名,按以下顺序排列:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
className={cn(
|
||||||
|
// 1. 布局(display, position, flex, grid)
|
||||||
|
'flex items-center justify-between',
|
||||||
|
// 2. 尺寸(width, height, padding, margin)
|
||||||
|
'w-full h-10 px-4',
|
||||||
|
// 3. 外观(background, border, shadow, rounded)
|
||||||
|
'rounded-md border border-input bg-background shadow-sm',
|
||||||
|
// 4. 文字(color, font, text-align)
|
||||||
|
'text-sm font-medium text-foreground',
|
||||||
|
// 5. 交互(hover, focus, disabled, cursor)
|
||||||
|
'hover:bg-accent focus-visible:ring-2 disabled:opacity-50',
|
||||||
|
// 6. 动画(transition, animate)
|
||||||
|
'transition-colors duration-150',
|
||||||
|
// 7. 条件类
|
||||||
|
isActive && 'bg-primary text-primary-foreground',
|
||||||
|
// 8. 外部传入
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 颜色使用检查清单
|
||||||
|
|
||||||
|
- [ ] 所有颜色使用 CSS 变量(`bg-background` 而非 `bg-white`)
|
||||||
|
- [ ] 边框使用 `border-border` 及其透明度变体
|
||||||
|
- [ ] 文字层级使用 `foreground` → `muted-foreground` → `muted-foreground/60`
|
||||||
|
- [ ] 错误状态使用 `destructive` 系列
|
||||||
|
- [ ] 工具色彩仅用于装饰性元素
|
||||||
|
|
||||||
|
### 10.3 组件文件组织
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ui/ # shadcn 基础组件(只读,不修改)
|
||||||
|
├── components/ # 业务组件
|
||||||
|
│ ├── CopyButton.tsx
|
||||||
|
│ ├── SwitchButtonGroup.tsx
|
||||||
|
│ ├── TextInputArea.tsx
|
||||||
|
│ └── ...
|
||||||
|
├── pages/ # 页面组件
|
||||||
|
│ ├── <ToolName>/
|
||||||
|
│ │ ├── index.tsx # 页面入口
|
||||||
|
│ │ ├── use<ToolName>.ts # 业务逻辑 Hook
|
||||||
|
│ │ └── components/ # 页面私有组件
|
||||||
|
│ └── ...
|
||||||
|
└── providers/ # Context Providers
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 新增页面模板
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/pages/NewTool/index.tsx
|
||||||
|
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { t } = useI18n('newTool');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
|
||||||
|
{/* 页面内容 */}
|
||||||
|
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
|
||||||
|
<h4 className="font-bold text-sm tracking-tight">{t('newTool:title')}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 反模式清单
|
||||||
|
|
||||||
|
以下模式**禁止**在项目中使用:
|
||||||
|
|
||||||
|
### 11.1 色彩反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 硬编码颜色
|
||||||
|
<div className="bg-white text-black">
|
||||||
|
<div className="bg-gray-100">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
|
||||||
|
// ❌ 使用非语义化 Tailwind 颜色
|
||||||
|
<div className="bg-slate-50">
|
||||||
|
<div className="text-zinc-400">
|
||||||
|
|
||||||
|
// ✅ 使用 CSS 变量
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
<div className="bg-muted text-muted-foreground">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 布局反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 固定高度导致内容截断
|
||||||
|
<div className="h-[200px]">
|
||||||
|
|
||||||
|
// ✅ 使用 min-height 或自适应
|
||||||
|
<div className="min-h-[200px]">
|
||||||
|
<div className="h-auto">
|
||||||
|
|
||||||
|
// ❌ 使用 margin 做组件间距
|
||||||
|
<div className="mb-4">
|
||||||
|
|
||||||
|
// ✅ 使用 gap
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 组件反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 修改 shadcn/ui 基础组件样式
|
||||||
|
// 如需修改,通过 className 覆盖或创建包装组件
|
||||||
|
|
||||||
|
// ❌ 内联样式用于颜色(工具色彩除外)
|
||||||
|
<div style={{ backgroundColor: '#f1f5f9' }}>
|
||||||
|
|
||||||
|
// ❌ 混合使用不同圆角体系
|
||||||
|
<Button className="rounded-lg"> // Button 应为 rounded-md
|
||||||
|
|
||||||
|
// ❌ 忽略焦点状态
|
||||||
|
<button className="..."> // 缺少 focus-visible 样式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 暗色模式反模式
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 仅适配部分元素
|
||||||
|
<div className="bg-white text-black dark:bg-gray-900 dark:text-white">
|
||||||
|
|
||||||
|
// ✅ 使用 CSS 变量自动适配
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
|
||||||
|
// ❌ 暗色模式下使用不合适的透明度
|
||||||
|
<div className="bg-black/5 dark:bg-white/5"> // 对比度不足
|
||||||
|
|
||||||
|
// ✅ 使用语义化变量
|
||||||
|
<div className="bg-muted">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 附录
|
||||||
|
|
||||||
|
### 12.1 常用类名速查
|
||||||
|
|
||||||
|
```
|
||||||
|
// 页面容器
|
||||||
|
p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none
|
||||||
|
|
||||||
|
// 标准卡片
|
||||||
|
p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm
|
||||||
|
|
||||||
|
// 工具栏
|
||||||
|
flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60
|
||||||
|
|
||||||
|
// 区域标签
|
||||||
|
text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider
|
||||||
|
|
||||||
|
// 数据展示
|
||||||
|
font-mono font-semibold text-foreground text-sm
|
||||||
|
|
||||||
|
// 错误提示
|
||||||
|
text-xs font-medium text-destructive
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center
|
||||||
|
|
||||||
|
// 图标按钮容器
|
||||||
|
flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background shadow-sm
|
||||||
|
|
||||||
|
// 焦点环
|
||||||
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 相关文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ------------------------------------- | ----------------------- |
|
||||||
|
| `src/index.css` | CSS 变量定义、全局样式 |
|
||||||
|
| `tailwind.config.js` | Tailwind 配置、色彩映射 |
|
||||||
|
| `src/lib/utils.ts` | `cn()` 工具函数 |
|
||||||
|
| `src/components/ui/*.tsx` | shadcn/ui 基础组件 |
|
||||||
|
| `src/config/features.tsx` | 工具配置、色彩分配 |
|
||||||
|
| `src/providers/ThemeModeProvider.tsx` | 主题模式管理 |
|
||||||
|
|
||||||
|
### 12.3 参考资源
|
||||||
|
|
||||||
|
- [shadcn/ui 文档](https://ui.shadcn.com/docs)
|
||||||
|
- [Tailwind CSS 文档](https://tailwindcss.com/docs)
|
||||||
|
- [Radix UI 文档](https://www.radix-ui.com/)
|
||||||
|
- [Lucide Icons](https://lucide.dev/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_本文档随项目迭代更新。新增组件或修改视觉风格时,请同步更新此文档。_
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
|
||||||
import { browser } from 'wxt/browser';
|
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
|
||||||
|
|
||||||
export default defineBackground(() => {
|
|
||||||
// 监听扩展图标点击事件,打开侧边栏
|
|
||||||
browser.action.onClicked.addListener(async (tab) => {
|
|
||||||
if (tab.id) {
|
|
||||||
try {
|
|
||||||
await browser.sidePanel.open({ tabId: tab.id });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to open side panel:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 使用 @webext-core/messaging 处理消息
|
|
||||||
onMessage(MessageAction.RELOAD_TAB, async (message) => {
|
|
||||||
const { tabId, delay = 0 } = message.data;
|
|
||||||
|
|
||||||
const executeReload = () => {
|
|
||||||
browser.tabs.reload(tabId);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (delay > 0) {
|
|
||||||
setTimeout(executeReload, delay);
|
|
||||||
} else {
|
|
||||||
executeReload();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, message: '刷新请求已接收' };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import '../.wxt/types/imports.d.ts';
|
|
||||||
import { initFormMappingHelper } from '@/utils/formMapping/ui';
|
|
||||||
import { initMessageHandler } from './content/messageHandler';
|
|
||||||
|
|
||||||
export default defineContentScript({
|
|
||||||
matches: ['<all_urls>'],
|
|
||||||
runAt: 'document_end',
|
|
||||||
main() {
|
|
||||||
// 初始化表单映射助手逻辑 (UI, Picker, Highlighter)
|
|
||||||
initFormMappingHelper();
|
|
||||||
|
|
||||||
// 初始化消息处理器 (Scan, Fill, Clear, Highlight, Flash, Inject)
|
|
||||||
initMessageHandler();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import {
|
|
||||||
fillAllFields,
|
|
||||||
clearAllFields,
|
|
||||||
fillSelectedFields,
|
|
||||||
scanFormFields,
|
|
||||||
highlightField,
|
|
||||||
unhighlightField,
|
|
||||||
flashField,
|
|
||||||
FillMode,
|
|
||||||
type FormFieldInfo,
|
|
||||||
} from '@/utils/dummyDataGenerator';
|
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
|
||||||
import {
|
|
||||||
FuzzyMatcher,
|
|
||||||
SmartInjectionEngine,
|
|
||||||
FeedbackRenderer,
|
|
||||||
} from '@/utils/formMapping/smartInjector';
|
|
||||||
import { FormMapEntry } from '@/types/storage';
|
|
||||||
|
|
||||||
// 存储当前扫描到的字段列表,用于高亮联动
|
|
||||||
let currentFields: FormFieldInfo[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化消息处理器
|
|
||||||
*/
|
|
||||||
export function initMessageHandler() {
|
|
||||||
onMessage(MessageAction.SCAN_FORM_FIELDS, async () => {
|
|
||||||
const result = scanFormFields();
|
|
||||||
currentFields = result.fields;
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
fields: result.fields.map((f) => ({
|
|
||||||
id: f.id,
|
|
||||||
fieldType: f.fieldType,
|
|
||||||
label: f.label,
|
|
||||||
placeholder: f.placeholder,
|
|
||||||
name: f.name,
|
|
||||||
value: f.value,
|
|
||||||
isSelected: f.isSelected,
|
|
||||||
generatedValue: f.generatedValue,
|
|
||||||
})),
|
|
||||||
totalCount: result.totalCount,
|
|
||||||
validCount: result.validCount,
|
|
||||||
hasModal: !!result.modalContainer,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FILL_VALID_DATA, async (message) => {
|
|
||||||
fillAllFields(FillMode.VALID, message.data.includeHidden || false);
|
|
||||||
return { success: true, message: '已填充有效数据' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FILL_INVALID_DATA, async (message) => {
|
|
||||||
fillAllFields(FillMode.INVALID, message.data.includeHidden || false);
|
|
||||||
return { success: true, message: '已填充无效数据' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FILL_SELECTED_FIELDS, async (message) => {
|
|
||||||
const { fields: incomingFields, mode } = message.data;
|
|
||||||
const fieldsToFill = currentFields.map((field) => {
|
|
||||||
const incomingField = incomingFields.find((f) => f.id === field.id);
|
|
||||||
if (incomingField) {
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
fieldType: incomingField.fieldType,
|
|
||||||
isSelected: incomingField.isSelected,
|
|
||||||
useInvalidData: incomingField.useInvalidData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
});
|
|
||||||
const count = fillSelectedFields(fieldsToFill, mode || FillMode.VALID);
|
|
||||||
return { success: true, message: `已填充 ${count} 个字段` };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.CLEAR_ALL_FIELDS, async () => {
|
|
||||||
clearAllFields();
|
|
||||||
return { success: true, message: '已清空所有字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.HIGHLIGHT_FIELD, async (message) => {
|
|
||||||
const { fieldId } = message.data;
|
|
||||||
const field = currentFields.find((f) => f.id === fieldId);
|
|
||||||
if (field) {
|
|
||||||
highlightField(field.element);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, message: '未找到字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.UNHIGHLIGHT_FIELD, async (message) => {
|
|
||||||
const { fieldId } = message.data;
|
|
||||||
const field = currentFields.find((f) => f.id === fieldId);
|
|
||||||
if (field) {
|
|
||||||
unhighlightField(field.element);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, message: '未找到字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.HIGHLIGHT_ALL_FIELDS, async (message) => {
|
|
||||||
const { fieldIds } = message.data;
|
|
||||||
fieldIds.forEach((id) => {
|
|
||||||
const field = currentFields.find((f) => f.id === id);
|
|
||||||
if (field) {
|
|
||||||
highlightField(field.element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.UNHIGHLIGHT_ALL_FIELDS, async () => {
|
|
||||||
currentFields.forEach((field) => {
|
|
||||||
unhighlightField(field.element);
|
|
||||||
});
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FLASH_FIELD, async (message) => {
|
|
||||||
const { fieldId } = message.data;
|
|
||||||
const field = currentFields.find((f) => f.id === fieldId);
|
|
||||||
if (field) {
|
|
||||||
flashField(field.element);
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
return { success: false, message: '未找到字段' };
|
|
||||||
});
|
|
||||||
|
|
||||||
onMessage(MessageAction.FORM_INJECT, async (message) => {
|
|
||||||
try {
|
|
||||||
const injectData =
|
|
||||||
(message.data.data as Array<{ entry: FormMapEntry; mockValue: string }>) || [];
|
|
||||||
const results = injectData.map((item) => {
|
|
||||||
const matchResult = FuzzyMatcher.findTargetElement(item.entry.fingerprint);
|
|
||||||
if (matchResult.element) {
|
|
||||||
const injectResult = SmartInjectionEngine.inject(
|
|
||||||
matchResult.element,
|
|
||||||
item.entry,
|
|
||||||
item.mockValue,
|
|
||||||
);
|
|
||||||
if (injectResult.success) {
|
|
||||||
FeedbackRenderer.renderSuccess(matchResult.element);
|
|
||||||
} else {
|
|
||||||
FeedbackRenderer.renderError(matchResult.element);
|
|
||||||
}
|
|
||||||
return { id: item.entry.id, success: injectResult.success };
|
|
||||||
} else {
|
|
||||||
return { id: item.entry.id, success: false };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { success: true, results };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('智能注入失败:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error instanceof Error ? error.message : '注入失败',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
Switch,
|
|
||||||
Button,
|
|
||||||
CircularProgress,
|
|
||||||
Stack,
|
|
||||||
IconButton,
|
|
||||||
} from '@mui/material';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import {
|
|
||||||
getFeatureByKey,
|
|
||||||
getDefaultPageOrder,
|
|
||||||
getDefaultVisibleFeatureKeys,
|
|
||||||
} from '@/config/features';
|
|
||||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>([]);
|
|
||||||
const [pageOrder, setPageOrder] = useState<PageType[]>([]);
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const { snackbarProps, showMessage } = useSnackbarState();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig().catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadConfig = async () => {
|
|
||||||
try {
|
|
||||||
const [savedVisible, savedOrder] = await Promise.all([
|
|
||||||
storageUtil.get('app/visiblePages', getDefaultVisibleFeatureKeys()),
|
|
||||||
storageUtil.get('app/pageOrder', getDefaultPageOrder()),
|
|
||||||
]);
|
|
||||||
setVisiblePages(savedVisible ?? getDefaultVisibleFeatureKeys());
|
|
||||||
setPageOrder(savedOrder && savedOrder.length > 0 ? savedOrder : getDefaultPageOrder());
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load config:', error);
|
|
||||||
setVisiblePages(getDefaultVisibleFeatureKeys());
|
|
||||||
setPageOrder(getDefaultPageOrder());
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageToggle = async (page: PageType) => {
|
|
||||||
const isCurrentlyVisible = visiblePages.includes(page);
|
|
||||||
let newPages: PageType[];
|
|
||||||
|
|
||||||
if (isCurrentlyVisible) {
|
|
||||||
if (visiblePages.length <= 1) {
|
|
||||||
showToast('至少需要保留一个可见页面', 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
newPages = visiblePages.filter((p) => p !== page);
|
|
||||||
} else {
|
|
||||||
newPages = [...visiblePages, page];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storageUtil.set('app/visiblePages', newPages);
|
|
||||||
setVisiblePages(newPages);
|
|
||||||
const feature = getFeatureByKey(page);
|
|
||||||
showToast(`已${isCurrentlyVisible ? '隐藏' : '显示'} ${feature?.label || page}`, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save config:', error);
|
|
||||||
showToast('保存失败', 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMove = async (index: number, direction: 'up' | 'down') => {
|
|
||||||
if (direction === 'up' && index === 0) return;
|
|
||||||
if (direction === 'down' && index === pageOrder.length - 1) return;
|
|
||||||
|
|
||||||
const newOrder = [...pageOrder];
|
|
||||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
|
||||||
[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await storageUtil.set('app/pageOrder', newOrder);
|
|
||||||
setPageOrder(newOrder);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save order:', error);
|
|
||||||
showToast('排序保存失败', 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestoreDefaults = async () => {
|
|
||||||
try {
|
|
||||||
const { getDefaultVisibleFeatureKeys } = await import('@/config/features');
|
|
||||||
const defaults = getDefaultVisibleFeatureKeys();
|
|
||||||
const defaultOrder = getDefaultPageOrder();
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
storageUtil.set('app/visiblePages', defaults),
|
|
||||||
storageUtil.set('app/pageOrder', defaultOrder),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setVisiblePages(defaults);
|
|
||||||
setPageOrder(defaultOrder);
|
|
||||||
showToast('已恢复默认', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to restore defaults:', error);
|
|
||||||
showToast('恢复失败', 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showToast = (message: string, severity: 'success' | 'info' | 'warning') => {
|
|
||||||
showMessage(message, { severity });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
|
|
||||||
>
|
|
||||||
<CircularProgress size={24} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
className="app"
|
|
||||||
sx={{ p: 4, minHeight: '100vh', bgcolor: 'grey.50', display: 'block', overflowY: 'auto' }}
|
|
||||||
>
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Box sx={{ maxWidth: 600, mx: 'auto' }}>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="flex-start"
|
|
||||||
sx={{ mb: 4 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
onClick={handleRestoreDefaults}
|
|
||||||
startIcon={<RefreshIcon sx={{ fontSize: 16 }} />}
|
|
||||||
sx={{ color: 'text.secondary', fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
恢复默认
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
overflow: 'hidden',
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
||||||
{pageOrder.map((key, index, array) => {
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (!feature) return null;
|
|
||||||
|
|
||||||
const isChecked = visiblePages.includes(key);
|
|
||||||
const isDisabled = isChecked && visiblePages.length === 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={key}
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 2.5,
|
|
||||||
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography variant="body1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
|
||||||
{feature.label}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{isChecked ? '已在 Dashboard 启用' : '已在 Dashboard 隐藏'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleMove(index, 'up')}
|
|
||||||
disabled={index === 0}
|
|
||||||
sx={{ color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
<KeyboardArrowUpIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => handleMove(index, 'down')}
|
|
||||||
disabled={index === array.length - 1}
|
|
||||||
sx={{ color: 'text.secondary' }}
|
|
||||||
>
|
|
||||||
<KeyboardArrowDownIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={() => handlePageToggle(key)}
|
|
||||||
disabled={isDisabled}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</ErrorBoundary>
|
|
||||||
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>扩展设置 - Testing Tools</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/config/theme';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import RouterProvider from '@/providers/RouterProvider';
|
|
||||||
import TopBar from '@/components/TopBar';
|
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
|
||||||
import { globalStyles } from '@/config/pageTheme';
|
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
// 打开Chrome扩展选项页面,需确保manifest中已配置options_page或options_ui
|
|
||||||
const handleOpenOptions = () => {
|
|
||||||
chrome.runtime.openOptionsPage().catch((r) => console.error(r));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RouterProvider syncKey="app/popupRoute">
|
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500000 }}>
|
|
||||||
<Box
|
|
||||||
className="app"
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
width: '400px',
|
|
||||||
height: '600px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: globalStyles.backgroundColor,
|
|
||||||
'@media screen and (min-width: 401px), screen and (min-height: 601px)': {
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
minWidth: '400px',
|
|
||||||
minHeight: '600px',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TopBar onOpenOptions={handleOpenOptions} />
|
|
||||||
<ErrorBoundary>
|
|
||||||
<RouterContainer />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</Box>
|
|
||||||
</SnackbarProvider>
|
|
||||||
</RouterProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>我是独立窗口</title>
|
|
||||||
<meta name="manifest.type" content="browser_action" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/config/theme';
|
|
||||||
import App from './App.tsx';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
|
||||||
import DashboardCard from '@/components/DashboardCard';
|
|
||||||
import { getFeatureByKey } from '@/config/features';
|
|
||||||
import type { PageType } from '@/types/storage';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
import { dashboardPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const { navigateTo, visiblePages, pageOrder } = useRouter();
|
|
||||||
|
|
||||||
const isVisible = (key: string) => visiblePages.includes(key as PageType);
|
|
||||||
|
|
||||||
const handleCardClick = useCallback(
|
|
||||||
(page: PageType) => {
|
|
||||||
navigateTo(page);
|
|
||||||
},
|
|
||||||
[navigateTo],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2 }}>
|
|
||||||
{pageOrder.map((key) => {
|
|
||||||
if (!isVisible(key)) return null;
|
|
||||||
|
|
||||||
const feature = getFeatureByKey(key);
|
|
||||||
if (!feature || !feature.icon || !feature.themeColor) return null;
|
|
||||||
|
|
||||||
// 适配 DashboardCard 组件,将 themeColor 映射到 colorCode
|
|
||||||
const cardConfig = {
|
|
||||||
title: feature.label,
|
|
||||||
description: feature.description,
|
|
||||||
colorCode: feature.themeColor,
|
|
||||||
icon: feature.icon,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DashboardCard
|
|
||||||
key={key}
|
|
||||||
config={cardConfig}
|
|
||||||
onClick={() => handleCardClick(key)}
|
|
||||||
cardBackgroundColor={dashboardPageStyles.cardBackgroundColor}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Container,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
|
||||||
Switch,
|
|
||||||
Divider,
|
|
||||||
Paper,
|
|
||||||
Chip,
|
|
||||||
} from '@mui/material';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import { FormMapEntry } from '@/types/storage';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { formMappingPageStyles } from '@/config/pageTheme.ts';
|
|
||||||
import { MockDataGenerator } from '@/utils/formMapping/smartInjector';
|
|
||||||
import { Button } from '@/components/Button';
|
|
||||||
import { FormInjectResult, MessageAction, sendMessage } from '@/utils/messages';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
|
|
||||||
export default function FormFillPage() {
|
|
||||||
const [entries, setEntries] = useState<FormMapEntry[]>([]);
|
|
||||||
const [previewData, setPreviewData] = useState<Map<string, string>>(new Map());
|
|
||||||
const [injectResults, setInjectResults] = useState<Map<string, boolean>>(new Map());
|
|
||||||
const [isInjecting, setIsInjecting] = useState(false);
|
|
||||||
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
|
|
||||||
|
|
||||||
const generatePreviewData = useCallback((items: FormMapEntry[]) => {
|
|
||||||
const preview = new Map<string, string>();
|
|
||||||
items.forEach((entry) => {
|
|
||||||
const value = MockDataGenerator.generate(entry.action_logic, entry);
|
|
||||||
preview.set(entry.id, value);
|
|
||||||
});
|
|
||||||
setPreviewData(preview);
|
|
||||||
setInjectResults(new Map());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadEntries = async () => {
|
|
||||||
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
|
|
||||||
setEntries(data || []);
|
|
||||||
generatePreviewData(data || []);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadEntries().catch((r) => console.error(r));
|
|
||||||
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
|
|
||||||
if (area === 'local' && changes['active_form_map']) {
|
|
||||||
loadEntries().catch((r) => console.error(r));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
chrome.storage.onChanged.addListener(listener);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(listener);
|
|
||||||
}, [generatePreviewData]);
|
|
||||||
|
|
||||||
const refreshPreview = () => {
|
|
||||||
generatePreviewData(entries);
|
|
||||||
};
|
|
||||||
|
|
||||||
const injectAllFields = async () => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
showMessage('没有可填充的字段', { severity: 'error' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsInjecting(true);
|
|
||||||
const results = new Map<string, boolean>();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 发送消息到 content script 执行注入
|
|
||||||
const response = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
||||||
if (response.length === 0) {
|
|
||||||
throw new Error('无法获取当前标签页');
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabId = response[0].id;
|
|
||||||
if (!tabId) {
|
|
||||||
throw new Error('标签页ID无效');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 准备注入数据
|
|
||||||
const injectData = entries.map((entry) => ({
|
|
||||||
entry,
|
|
||||||
mockValue: previewData.get(entry.id) || '',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// 执行注入 (使用 type-safe sendMessage)
|
|
||||||
const result = await sendMessage(MessageAction.FORM_INJECT, { data: injectData }, tabId);
|
|
||||||
|
|
||||||
if (result && result.success) {
|
|
||||||
result.results?.forEach((r: FormInjectResult) => {
|
|
||||||
results.set(r.id, r.success);
|
|
||||||
});
|
|
||||||
setInjectResults(results);
|
|
||||||
showMessage('填充成功!', { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
throw new Error(result?.error || result?.message || '注入失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('注入失败:', error);
|
|
||||||
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单');
|
|
||||||
showMessage(error instanceof Error ? error.message : '注入失败,请确保已在网页中打开表单', {
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsInjecting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFieldTypeLabel = (type: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
text: '文本',
|
|
||||||
select: '下拉框',
|
|
||||||
checkbox: '复选框',
|
|
||||||
radio: '单选框',
|
|
||||||
};
|
|
||||||
return labels[type] || type;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStrategyLabel = (strategy: string) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
fixed: '固定值',
|
|
||||||
random: '随机',
|
|
||||||
sequence: '序列',
|
|
||||||
};
|
|
||||||
return labels[strategy] || strategy;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<PageHeader
|
|
||||||
title="智能表单填充"
|
|
||||||
subtitle="基于指纹识别的精准数据注入"
|
|
||||||
icon={<PlayArrowIcon />}
|
|
||||||
/>
|
|
||||||
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
|
|
||||||
{/* 操作区域 */}
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
mb: 2.5,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
|
|
||||||
填充控制
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={refreshPreview}
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
>
|
|
||||||
刷新预览
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={injectAllFields}
|
|
||||||
size="small"
|
|
||||||
startIcon={<PlayArrowIcon />}
|
|
||||||
disabled={isInjecting || entries.length === 0}
|
|
||||||
sx={{
|
|
||||||
bgcolor: formMappingPageStyles.secondaryColor || '#9c27b0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isInjecting ? '注入中...' : '开始填充'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
点击"开始填充"后,将根据映射配置向网页表单注入数据。
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* 字段列表 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
px: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
|
|
||||||
映射字段 ({entries.length})
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<List
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary="暂无映射字段"
|
|
||||||
secondary="请先在表单映射页面配置字段"
|
|
||||||
slotProps={{
|
|
||||||
primary: {
|
|
||||||
align: 'center',
|
|
||||||
color: 'text.secondary',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
entries.map((entry, index) => (
|
|
||||||
<Box key={entry.id}>
|
|
||||||
{index > 0 && <Divider />}
|
|
||||||
<ListItem
|
|
||||||
secondaryAction={
|
|
||||||
<IconButton edge="end" aria-label="preview">
|
|
||||||
<VisibilityIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
sx={{ py: 1.5 }}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
edge="start"
|
|
||||||
checked={entry.ui_state.is_selected}
|
|
||||||
disabled
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
/>
|
|
||||||
<ListItemText
|
|
||||||
slotProps={{
|
|
||||||
primary: {
|
|
||||||
component: 'div',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
component: 'div',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
primary={
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
||||||
<span style={{ fontWeight: 500 }}>{entry.label_display}</span>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={getFieldTypeLabel(entry.action_logic.type)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
color: 'grey.700',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={getStrategyLabel(entry.action_logic.strategy)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
bgcolor: formMappingPageStyles.secondaryColor + '20',
|
|
||||||
color: formMappingPageStyles.secondaryColor || '#9c27b0',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
color: 'text.secondary',
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.fingerprint.selector}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: 'primary.main',
|
|
||||||
fontStyle: 'italic',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
maxWidth: '250px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
预览: {previewData.get(entry.id) || '---'}
|
|
||||||
</Typography>
|
|
||||||
{injectResults.has(entry.id) &&
|
|
||||||
(injectResults.get(entry.id) ? (
|
|
||||||
<CheckCircleIcon sx={{ color: '#32CD32', fontSize: '1rem' }} />
|
|
||||||
) : (
|
|
||||||
<CancelIcon sx={{ color: '#FF4444', fontSize: '1rem' }} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{/* 统计信息 */}
|
|
||||||
{injectResults.size > 0 && (
|
|
||||||
<Box sx={{ mt: 4 }}>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-around' }}>
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Typography variant="h5" fontWeight={800} color="primary.main">
|
|
||||||
{Array.from(injectResults.values()).filter(Boolean).length}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
成功注入
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider orientation="vertical" flexItem />
|
|
||||||
<Box textAlign="center">
|
|
||||||
<Typography variant="h5" fontWeight={800} color="error.main">
|
|
||||||
{Array.from(injectResults.values()).filter((v) => !v).length}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
注入失败
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Container,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
IconButton,
|
|
||||||
Switch,
|
|
||||||
Divider,
|
|
||||||
Paper,
|
|
||||||
} from '@mui/material';
|
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
|
||||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
|
||||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
|
||||||
import FileDownloadIcon from '@mui/icons-material/FileDownload';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import { FormMapEntry } from '@/types/storage';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
|
|
||||||
export default function FormMappingPage() {
|
|
||||||
const [entries, setEntries] = useState<FormMapEntry[]>([]);
|
|
||||||
const [isPicking, setIsPicking] = useState(false);
|
|
||||||
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 3000 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadData = async () => {
|
|
||||||
const data = (await storageUtil.get('active_form_map')) as FormMapEntry[];
|
|
||||||
setEntries(data || []);
|
|
||||||
const picking = (await storageUtil.get('app/formMapping/isPicking')) as boolean;
|
|
||||||
setIsPicking(picking || false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData().catch((r) => console.error(r));
|
|
||||||
|
|
||||||
const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => {
|
|
||||||
if (area === 'local') {
|
|
||||||
if (changes['active_form_map']) {
|
|
||||||
setEntries((changes['active_form_map'].newValue as FormMapEntry[]) || []);
|
|
||||||
}
|
|
||||||
if (changes['app/formMapping/isPicking']) {
|
|
||||||
setIsPicking((changes['app/formMapping/isPicking'].newValue as boolean) || false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(listener);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(listener);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePicking = async () => {
|
|
||||||
await storageUtil.set('app/formMapping/isPicking', !isPicking);
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteEntry = async (id: string) => {
|
|
||||||
const newEntries = entries.filter((e) => e.id !== id);
|
|
||||||
await storageUtil.set('active_form_map', newEntries);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSelection = async (id: string) => {
|
|
||||||
const newEntries = entries.map((e) =>
|
|
||||||
e.id === id ? { ...e, ui_state: { ...e.ui_state, is_selected: !e.ui_state.is_selected } } : e,
|
|
||||||
);
|
|
||||||
await storageUtil.set('active_form_map', newEntries);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = async () => {
|
|
||||||
await storageUtil.set('active_form_map', []);
|
|
||||||
await storageUtil.set('app/formMapping/isPicking', false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportConfig = () => {
|
|
||||||
try {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
showMessage('没有可导出的配置数据', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonStr = JSON.stringify(entries, null, 2);
|
|
||||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
const date = new Date();
|
|
||||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
||||||
const filename = `form-mapping-config-${dateStr}.json`;
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
showMessage('配置导出成功!', { severity: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('导出配置失败:', error);
|
|
||||||
showMessage(error instanceof Error ? error.message : '导出失败,请重试', {
|
|
||||||
severity: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<PageHeader
|
|
||||||
title="通用表单映射助手"
|
|
||||||
subtitle="智能识别表单指纹,自定义填充逻辑"
|
|
||||||
icon={<AutoFixHighIcon />}
|
|
||||||
/>
|
|
||||||
<Container maxWidth="sm" sx={{ py: 2, px: 0 }}>
|
|
||||||
<Paper
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
mb: 2.5,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle1" fontWeight={800} color="text.primary">
|
|
||||||
状态控制
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant={isPicking ? 'contained' : 'outlined'}
|
|
||||||
onClick={togglePicking}
|
|
||||||
size="small"
|
|
||||||
startIcon={<AddCircleOutlineIcon />}
|
|
||||||
>
|
|
||||||
{isPicking ? '正在拾取...' : '开始拾取'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
点击“开始拾取”后,直接在网页上点击想要映射的表单元素。
|
|
||||||
</Typography>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
px: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" fontWeight={800} color="text.secondary">
|
|
||||||
已拾取字段 ({entries.length})
|
|
||||||
</Typography>
|
|
||||||
<Button size="small" color="error" onClick={clearAll}>
|
|
||||||
清空全部
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<List
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entries.length === 0 ? (
|
|
||||||
<ListItem>
|
|
||||||
<ListItemText
|
|
||||||
primary="暂无数据"
|
|
||||||
secondary="点击上方按钮开始探测网页表单"
|
|
||||||
slotProps={{
|
|
||||||
primary: {
|
|
||||||
align: 'center',
|
|
||||||
color: 'text.secondary',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
align: 'center',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
) : (
|
|
||||||
entries.map((entry, index) => (
|
|
||||||
<Box key={entry.id}>
|
|
||||||
{index > 0 && <Divider />}
|
|
||||||
<ListItem
|
|
||||||
secondaryAction={
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
aria-label="delete"
|
|
||||||
onClick={() => deleteEntry(entry.id)}
|
|
||||||
sx={{ color: 'error.light' }}
|
|
||||||
>
|
|
||||||
<DeleteIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
sx={{ py: 1.5 }}
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
edge="start"
|
|
||||||
checked={entry.ui_state.is_selected}
|
|
||||||
onChange={() => toggleSelection(entry.id)}
|
|
||||||
/>
|
|
||||||
<ListItemText
|
|
||||||
primary={entry.label_display}
|
|
||||||
secondary={entry.fingerprint.selector}
|
|
||||||
slotProps={{
|
|
||||||
primary: { fontWeight: 500 },
|
|
||||||
secondary: {
|
|
||||||
sx: {
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: '200px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{entries.length > 0 && (
|
|
||||||
<Box sx={{ mt: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
mb: 1.5,
|
|
||||||
px: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'text.secondary' }}>
|
|
||||||
映射配置导出 (JSON)
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
onClick={exportConfig}
|
|
||||||
startIcon={<FileDownloadIcon />}
|
|
||||||
>
|
|
||||||
导出配置
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
maxHeight: '180px',
|
|
||||||
overflow: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<pre style={{ margin: 0 }}>{JSON.stringify(entries, null, 2)}</pre>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Box, Container, CircularProgress, FormControlLabel, Switch } from '@mui/material';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import InputIcon from '@mui/icons-material/Input';
|
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
|
||||||
import { formRecognizerPageStyles } from '@/config/pageTheme';
|
|
||||||
import FieldList from '@/components/FieldList';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { useFormRecognizer } from './hooks/useFormRecognizer';
|
|
||||||
|
|
||||||
export default function FormRecognizerPage() {
|
|
||||||
const {
|
|
||||||
fillLoading,
|
|
||||||
clearLoading,
|
|
||||||
includeHidden,
|
|
||||||
setIncludeHidden,
|
|
||||||
fields,
|
|
||||||
scanning,
|
|
||||||
showFields,
|
|
||||||
setShowFields,
|
|
||||||
hoveredFieldId,
|
|
||||||
sidePanelOpen,
|
|
||||||
handleScanFields,
|
|
||||||
handleFieldTypeChange,
|
|
||||||
handleToggleFieldSelection,
|
|
||||||
handleToggleAllFields,
|
|
||||||
handleLocateField,
|
|
||||||
handleHoverField,
|
|
||||||
handleFillSelectedFields,
|
|
||||||
handleClearAllFields,
|
|
||||||
handleOpenSidePanel,
|
|
||||||
selectedCount,
|
|
||||||
} = useFormRecognizer();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container maxWidth="sm" sx={{ py: 3, px: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="表单测试数据填充器"
|
|
||||||
subtitle="一键填充表单测试数据,提升开发和测试效率"
|
|
||||||
icon={<InputIcon />}
|
|
||||||
iconColor={formRecognizerPageStyles.primaryColor}
|
|
||||||
sx={{ mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<OpenInNewIcon />}
|
|
||||||
onClick={handleOpenSidePanel}
|
|
||||||
sx={{
|
|
||||||
textTransform: 'none',
|
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
opacity: sidePanelOpen ? 0 : 1,
|
|
||||||
transform: sidePanelOpen ? 'scale(0.8)' : 'scale(1)',
|
|
||||||
pointerEvents: sidePanelOpen ? 'none' : 'auto',
|
|
||||||
visibility: sidePanelOpen ? 'hidden' : 'visible',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
侧边栏
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 扫描按钮 */}
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleScanFields}
|
|
||||||
disabled={scanning}
|
|
||||||
fullWidth
|
|
||||||
startIcon={scanning ? <CircularProgress size={16} color="inherit" /> : <InputIcon />}
|
|
||||||
>
|
|
||||||
{scanning ? '扫描中...' : '扫描表单字段'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<FieldList
|
|
||||||
fields={fields}
|
|
||||||
showFields={showFields}
|
|
||||||
onToggleShowFields={() => setShowFields(!showFields)}
|
|
||||||
onFieldTypeChange={handleFieldTypeChange}
|
|
||||||
onLocateField={handleLocateField}
|
|
||||||
onHoverField={handleHoverField}
|
|
||||||
onToggleFieldSelection={handleToggleFieldSelection}
|
|
||||||
onToggleAllFields={handleToggleAllFields}
|
|
||||||
hoveredFieldId={hoveredFieldId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Button
|
|
||||||
disableElevation
|
|
||||||
disableRipple
|
|
||||||
variant="contained"
|
|
||||||
onClick={handleFillSelectedFields}
|
|
||||||
disabled={fillLoading || selectedCount === 0}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
填充选中字段
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
disableElevation
|
|
||||||
disableRipple
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleClearAllFields}
|
|
||||||
disabled={clearLoading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
清空所有字段
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Switch
|
|
||||||
checked={includeHidden}
|
|
||||||
onChange={(e) => setIncludeHidden(e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="包含隐藏字段"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Box, Typography, Container } from '@mui/material';
|
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import UrlEntryForm from '@/components/UrlEntryForm';
|
|
||||||
import UrlEntryList from '@/components/UrlEntryList';
|
|
||||||
import { useUrlPreferences } from '@/utils/useUrlPreferences';
|
|
||||||
import type { OpenUrlEntry } from '@/types/storage';
|
|
||||||
import { openUrlPageStyles } from '@/config/pageTheme';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
|
|
||||||
export default function OpenUrlPage() {
|
|
||||||
const { entries, setEntries, isLoaded } = useUrlPreferences();
|
|
||||||
const { showMessage } = useGlobalSnackbar();
|
|
||||||
|
|
||||||
const handleAddEntry = (entry: OpenUrlEntry) => {
|
|
||||||
setEntries([...entries, entry]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteEntry = (index: number) => {
|
|
||||||
const newEntries = [...entries];
|
|
||||||
newEntries.splice(index, 1);
|
|
||||||
setEntries(newEntries);
|
|
||||||
showMessage('删除成功', { severity: 'success' });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ minHeight: '100%', pb: 3 }}>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<Typography>加载中...</Typography>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="URL 工具"
|
|
||||||
subtitle="快速打开 URL 或复制链接"
|
|
||||||
icon={<LanguageIcon />}
|
|
||||||
iconColor={openUrlPageStyles.primaryColor}
|
|
||||||
sx={{ mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Form Section */}
|
|
||||||
<UrlEntryForm onAddEntry={handleAddEntry} showMessage={showMessage} />
|
|
||||||
|
|
||||||
{/* List Section */}
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.secondary', fontWeight: 800, px: 1, mb: 1, display: 'block' }}
|
|
||||||
>
|
|
||||||
已保存的快捷方式 ({entries.length})
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<UrlEntryList
|
|
||||||
entries={entries}
|
|
||||||
onDeleteEntry={handleDeleteEntry}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { Box, Typography, CircularProgress, Alert } from '@mui/material';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
|
|
||||||
// 只允许 HTTP/HTTPS 协议,阻止危险协议
|
|
||||||
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
|
|
||||||
|
|
||||||
export default function OpenUrlViewerPage() {
|
|
||||||
const [currentUrl, setCurrentUrl] = useState<string>('');
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const [iframeLoading, setIframeLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 验证 URL 是否安全
|
|
||||||
const validateUrl = (url: string): string | null => {
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(url);
|
|
||||||
if (!ALLOWED_PROTOCOLS.includes(urlObj.protocol)) {
|
|
||||||
return `不支持的 URL 协议: ${urlObj.protocol}。仅允许 HTTP 和 HTTPS。`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return '无效的 URL 格式';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从存储加载当前选中的 URL
|
|
||||||
const loadCurrentUrl = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const saved = await storageUtil.get('openUrl/currentUrl', '');
|
|
||||||
if (saved) {
|
|
||||||
const validationError = validateUrl(saved);
|
|
||||||
if (validationError) {
|
|
||||||
setError(validationError);
|
|
||||||
} else {
|
|
||||||
setCurrentUrl(saved);
|
|
||||||
setError(null);
|
|
||||||
setIframeLoading(true); // 重置 iframe 加载状态
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setError(null);
|
|
||||||
setCurrentUrl('');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load current URL:', error);
|
|
||||||
setError('加载 URL 失败');
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
useEffect(() => {
|
|
||||||
loadCurrentUrl();
|
|
||||||
}, [loadCurrentUrl]);
|
|
||||||
|
|
||||||
// 监听存储变化,确保 URL 变更时能及时更新
|
|
||||||
useEffect(() => {
|
|
||||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
|
||||||
if (changes['openUrl/currentUrl']) {
|
|
||||||
loadCurrentUrl();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
|
||||||
}, [loadCurrentUrl]);
|
|
||||||
|
|
||||||
if (!isLoaded) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress size={40} />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, flex: 1 }}>
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
<Typography color="text.secondary">请返回 OpenUrl 页面选择有效的 URL。</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUrl) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, flex: 1 }}>
|
|
||||||
<Alert severity="info" sx={{ mb: 2 }}>
|
|
||||||
没有选中的 URL
|
|
||||||
</Alert>
|
|
||||||
<Typography color="text.secondary">请先在 OpenUrl 页面选择一个 URL 打开。</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 加载状态指示器 */}
|
|
||||||
{iframeLoading && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<CircularProgress size={60} />
|
|
||||||
<Typography sx={{ mt: 2 }}>加载中...</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<iframe
|
|
||||||
src={currentUrl}
|
|
||||||
title="OpenUrl Viewer"
|
|
||||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-navigation"
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
width: '100%',
|
|
||||||
border: 'none',
|
|
||||||
display: 'block',
|
|
||||||
}}
|
|
||||||
onLoad={() => setIframeLoading(false)}
|
|
||||||
onError={() => {
|
|
||||||
setIframeLoading(false);
|
|
||||||
setError('URL 加载失败,请检查网络连接或 URL 是否正确');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Box, Stack, Container, CircularProgress } from '@mui/material';
|
|
||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import UrlToQrCodeSection from '@/components/UrlToQrCodeSection';
|
|
||||||
import QrCodeToUrlSection from '@/components/QrCodeToUrlSection';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import { qrCodePageStyles } from '@/config/pageTheme';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
|
|
||||||
export default function QrCodePage() {
|
|
||||||
const { showMessage } = useGlobalSnackbar();
|
|
||||||
|
|
||||||
// 使用自定义钩子管理展开状态
|
|
||||||
const [urlExpanded, setUrlExpanded, urlInitialized] = useStorageState('qrCode/urlExpanded', true);
|
|
||||||
const [qrExpanded, setQrExpanded, qrInitialized] = useStorageState('qrCode/qrExpanded', false);
|
|
||||||
|
|
||||||
// 初始化未完成时显示加载状态
|
|
||||||
if (!urlInitialized || !qrInitialized) {
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
sx={{
|
|
||||||
py: 4,
|
|
||||||
maxWidth: 400,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: 200,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CircularProgress />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2, maxWidth: 400 }}>
|
|
||||||
<PageHeader
|
|
||||||
title="二维码工具"
|
|
||||||
subtitle="生成和解析二维码"
|
|
||||||
icon={<QrCodeIcon />}
|
|
||||||
iconColor={qrCodePageStyles.primaryColor}
|
|
||||||
sx={{ mb: 2.5 }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack spacing={3}>
|
|
||||||
<UrlToQrCodeSection
|
|
||||||
expanded={urlExpanded}
|
|
||||||
onExpandedChange={setUrlExpanded}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<QrCodeToUrlSection
|
|
||||||
expanded={qrExpanded}
|
|
||||||
onExpandedChange={setQrExpanded}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { Box, Container, CircularProgress } from '@mui/material';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import StorageCleanerConfirm from '@/components/StorageCleanerConfirm';
|
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
|
||||||
import { useStorageCleaner } from './useStorageCleaner';
|
|
||||||
import DomainHeader from './components/DomainHeader';
|
|
||||||
import StorageOptionsGrid from './components/StorageOptionsGrid';
|
|
||||||
import AutoRefreshToggle from './components/AutoRefreshToggle';
|
|
||||||
import ErrorDisplay from './components/ErrorDisplay';
|
|
||||||
import CleaningResult from './components/CleaningResult';
|
|
||||||
|
|
||||||
export default function StorageCleanerPage() {
|
|
||||||
const { showMessage } = useGlobalSnackbar();
|
|
||||||
const {
|
|
||||||
domain,
|
|
||||||
error,
|
|
||||||
isInitializing,
|
|
||||||
options,
|
|
||||||
sizes,
|
|
||||||
autoRefresh,
|
|
||||||
loading,
|
|
||||||
result,
|
|
||||||
showConfirm,
|
|
||||||
setShowConfirm,
|
|
||||||
totalSize,
|
|
||||||
allSelected,
|
|
||||||
someSelected,
|
|
||||||
handleAutoRefreshChange,
|
|
||||||
handleOptionChange,
|
|
||||||
handleSelectAll,
|
|
||||||
handleClean,
|
|
||||||
} = useStorageCleaner({ showMessage });
|
|
||||||
|
|
||||||
if (isInitializing) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
||||||
<CircularProgress size={24} color="warning" />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <ErrorDisplay error={error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ py: 2 }}>
|
|
||||||
<DomainHeader domain={domain} totalSize={totalSize} />
|
|
||||||
|
|
||||||
<StorageOptionsGrid
|
|
||||||
options={options}
|
|
||||||
sizes={sizes}
|
|
||||||
allSelected={allSelected}
|
|
||||||
someSelected={someSelected}
|
|
||||||
onOptionChange={handleOptionChange}
|
|
||||||
onSelectAll={handleSelectAll}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AutoRefreshToggle autoRefresh={autoRefresh} onChange={handleAutoRefreshChange} />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
onClick={() => setShowConfirm(true)}
|
|
||||||
sx={{
|
|
||||||
bgcolor: storageCleanerPageStyles.warningColor,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: storageCleanerPageStyles.warningDark,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
{loading ? '正在清理...' : '立即清理'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<CleaningResult result={result} />
|
|
||||||
</Container>
|
|
||||||
|
|
||||||
<StorageCleanerConfirm
|
|
||||||
open={showConfirm}
|
|
||||||
onClose={() => setShowConfirm(false)}
|
|
||||||
onConfirm={handleClean}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { TextField, Select, MenuItem, Stack, Box, Container } from '@mui/material';
|
|
||||||
import { useSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import Button from '@/components/Button';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { ZONES, timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import LiveClock from './components/LiveClock';
|
|
||||||
import ResultView from './components/ResultView';
|
|
||||||
import { useTimestampConverter } from './hooks/useTimestampConverter';
|
|
||||||
|
|
||||||
export default function TimestampPage() {
|
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const {
|
|
||||||
mode,
|
|
||||||
tsInput,
|
|
||||||
dtInput,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
setMode,
|
|
||||||
setTsInput,
|
|
||||||
setDtInput,
|
|
||||||
setUnit,
|
|
||||||
setZone,
|
|
||||||
handleUseNow,
|
|
||||||
convert,
|
|
||||||
} = useTimestampConverter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Container sx={{ p: 2 }}>
|
|
||||||
{/* Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="时间戳转换"
|
|
||||||
subtitle="Unix 毫秒数转换与格式化"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Live Clock Card */}
|
|
||||||
<LiveClock
|
|
||||||
unit={unit}
|
|
||||||
onUseNow={handleUseNow}
|
|
||||||
onUnitChange={setUnit}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Mode Switcher */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
p: 0.6,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
mb: 2.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
height: 'calc(100% - 10px)',
|
|
||||||
width: 'calc(50% - 5px)',
|
|
||||||
bgcolor: '#fff',
|
|
||||||
borderRadius: 3.5,
|
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
|
||||||
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
transform: mode === 'ts2dt' ? 'translateX(0)' : 'translateX(100%)',
|
|
||||||
top: 5,
|
|
||||||
left: 5,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{(['ts2dt', 'dt2ts'] as const).map((m) => (
|
|
||||||
<Box
|
|
||||||
key={m}
|
|
||||||
onClick={() => setMode(m)}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
py: 1,
|
|
||||||
textAlign: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
zIndex: 1,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
color: mode === m ? 'primary.main' : 'text.secondary',
|
|
||||||
transition: 'color 0.3s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{m === 'ts2dt' ? '时间戳 → 日期' : '日期 → 时间戳'}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Input Area */}
|
|
||||||
<Stack spacing={2} sx={{ mb: 3 }}>
|
|
||||||
<TextField
|
|
||||||
placeholder={mode === 'ts2dt' ? '输入时间戳...' : 'YYYY-MM-DD HH:mm:ss'}
|
|
||||||
value={mode === 'ts2dt' ? tsInput : dtInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(val);
|
|
||||||
} else {
|
|
||||||
setDtInput(val);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={!!error}
|
|
||||||
helperText={error}
|
|
||||||
fullWidth
|
|
||||||
sx={timestampPageStyles.INPUT_STYLE}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1.5}>
|
|
||||||
{/* 优化后的单位选择按钮组 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
bgcolor: 'grey.50',
|
|
||||||
p: 0.5,
|
|
||||||
borderRadius: 3.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(['ms', 's'] as const).map((u) => (
|
|
||||||
<Box
|
|
||||||
key={u}
|
|
||||||
onClick={() => setUnit(u)}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
py: 0.8,
|
|
||||||
textAlign: 'center',
|
|
||||||
borderRadius: 3,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 800,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
bgcolor: unit === u ? '#fff' : 'transparent',
|
|
||||||
color: unit === u ? 'primary.main' : 'text.disabled',
|
|
||||||
boxShadow: unit === u ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u === 'ms' ? '毫秒 (ms)' : '秒 (s)'}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
fullWidth
|
|
||||||
value={zone}
|
|
||||||
onChange={(e) => setZone(e.target.value as typeof zone)}
|
|
||||||
sx={{ ...timestampPageStyles.INPUT_STYLE, flex: 1, borderRadius: 4 }}
|
|
||||||
MenuProps={{
|
|
||||||
PaperProps: {
|
|
||||||
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ZONES.map((z) => (
|
|
||||||
<MenuItem key={z} value={z} sx={{ fontSize: '0.8rem', fontWeight: 600 }}>
|
|
||||||
{z}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Main Action */}
|
|
||||||
<Button fullWidth variant="contained" onClick={convert}>
|
|
||||||
立即转换
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Result View */}
|
|
||||||
<ResultView result={result} mode={mode} unit={unit} zone={zone} showMessage={showMessage} />
|
|
||||||
</Container>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Box, Switch, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
interface AutoRefreshToggleProps {
|
|
||||||
autoRefresh: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AutoRefreshToggle({ autoRefresh, onChange }: AutoRefreshToggleProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mb: 3,
|
|
||||||
p: 1.5,
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.05)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="body2" fontWeight={700} sx={{ fontSize: '0.8rem', px: 1.2 }}>
|
|
||||||
清理后自动刷新页面
|
|
||||||
</Typography>
|
|
||||||
<Switch
|
|
||||||
size="small"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
|
||||||
color="warning"
|
|
||||||
sx={{
|
|
||||||
'& .MuiSwitch-track': {
|
|
||||||
borderRadius: 20,
|
|
||||||
},
|
|
||||||
'& .MuiSwitch-thumb': {
|
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
},
|
|
||||||
'&:hover .MuiSwitch-thumb': {
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Box, Alert } from '@mui/material';
|
|
||||||
import type { CleaningResult } from '@/types/storage';
|
|
||||||
import { formatCleaningResult } from '@/utils/storageCleaner';
|
|
||||||
|
|
||||||
interface CleaningResultProps {
|
|
||||||
result: CleaningResult | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CleaningResult({ result }: CleaningResultProps) {
|
|
||||||
if (!result) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ mt: 3, animation: 'fadeIn 0.3s ease-in-out' }}>
|
|
||||||
<Alert
|
|
||||||
severity={result.success ? 'success' : 'error'}
|
|
||||||
sx={{
|
|
||||||
borderRadius: 3,
|
|
||||||
py: 1,
|
|
||||||
px: 2,
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
|
||||||
'& .MuiAlert-message': {
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
},
|
|
||||||
'& .MuiAlert-icon': {
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
mr: 1,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result.success ? formatCleaningResult(result) : result.error || '清理失败'}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Box } from '@mui/material';
|
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DomainHeader 组件属性接口
|
|
||||||
*/
|
|
||||||
interface DomainHeaderProps {
|
|
||||||
/** 当前域名 */
|
|
||||||
domain: string;
|
|
||||||
/** 已占用的存储大小(字节) */
|
|
||||||
totalSize: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DomainHeader - 存储清理页面标题栏组件
|
|
||||||
*
|
|
||||||
* 使用 PageHeader 组件构建,显示域名和已占用存储空间大小
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <DomainHeader
|
|
||||||
* domain="example.com"
|
|
||||||
* totalSize={1048576}
|
|
||||||
* />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export default function DomainHeader({ domain, totalSize }: DomainHeaderProps) {
|
|
||||||
return (
|
|
||||||
<PageHeader
|
|
||||||
icon={<StorageIcon sx={{ fontSize: 22 }} />}
|
|
||||||
iconColor={storageCleanerPageStyles.warningColor}
|
|
||||||
title="存储清理"
|
|
||||||
subtitle={domain || '加载中...'}
|
|
||||||
badge={
|
|
||||||
totalSize > 0 ? (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
|
||||||
color: storageCleanerPageStyles.warningColor,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.3,
|
|
||||||
borderRadius: 2,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
boxShadow: '0 2px 4px rgba(255, 152, 0, 0.2)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.25)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
已占用 {formatSize(totalSize)}
|
|
||||||
</Box>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
iconSx={{
|
|
||||||
p: 1.2,
|
|
||||||
borderRadius: 3,
|
|
||||||
boxShadow: '0 2px 8px rgba(255, 152, 0, 0.15)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(255, 152, 0, 0.15)',
|
|
||||||
transform: 'scale(1.05)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
titleSx={{
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
subtitleSx={{
|
|
||||||
display: 'block',
|
|
||||||
maxWidth: 240,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
mt: 0.3,
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
}}
|
|
||||||
sx={{ mb: 3 }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { Box, Container, Typography } from '@mui/material';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
|
|
||||||
interface ErrorDisplayProps {
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ErrorDisplay({ error }: ErrorDisplayProps) {
|
|
||||||
return (
|
|
||||||
<Container
|
|
||||||
sx={{
|
|
||||||
py: 8,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
minHeight: '400px',
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ width: '100%', maxWidth: 320 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
p: 4,
|
|
||||||
boxShadow: '0 8px 24px rgba(244, 67, 54, 0.15)',
|
|
||||||
border: '1px solid rgba(244, 67, 54, 0.2)',
|
|
||||||
bgcolor: 'rgba(244, 67, 54, 0.05)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<WarningIcon sx={{ fontSize: 36, color: 'error.main', mb: 2 }} />
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
color="error.main"
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.9rem',
|
|
||||||
fontWeight: 700,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
mb: 3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
存储清理功能仅适用于标准网页
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
||||||
import { Stack, Typography, Box, IconButton, Tooltip, Divider, alpha } from '@mui/material';
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface LiveClockProps {
|
|
||||||
unit: UnitType;
|
|
||||||
onUseNow: (val: number) => void;
|
|
||||||
onUnitChange: (u: UnitType) => void;
|
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow, onUnitChange, showMessage }: LiveClockProps) => {
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const onUseNowRef = useRef(onUseNow);
|
|
||||||
const showMessageRef = useRef(showMessage);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onUseNowRef.current = onUseNow;
|
|
||||||
showMessageRef.current = showMessage;
|
|
||||||
}, [onUseNow, showMessage]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
||||||
return () => clearInterval(t);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const displayVal = useMemo(
|
|
||||||
() => String(Math.floor(now / (unit === 'ms' ? 1 : 1000))),
|
|
||||||
[now, unit],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUseNow = useCallback(() => {
|
|
||||||
onUseNowRef.current(now);
|
|
||||||
showMessageRef.current?.('已使用当前时间戳', { severity: 'success' });
|
|
||||||
}, [now, showMessageRef]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
p: 1.8,
|
|
||||||
mb: 2.5,
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.04),
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack spacing={0.5}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
当前时间戳
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 800,
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: '1.2rem',
|
|
||||||
letterSpacing: '-0.5px',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayVal}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
|
||||||
{/* 胶囊式单位切换器 */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
p: 0.4,
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.08),
|
|
||||||
borderRadius: 2.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(['ms', 's'] as const).map((u) => (
|
|
||||||
<Box
|
|
||||||
key={u}
|
|
||||||
onClick={() => onUnitChange(u)}
|
|
||||||
sx={{
|
|
||||||
px: 1.2,
|
|
||||||
py: 0.35,
|
|
||||||
borderRadius: 2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 900,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
bgcolor: unit === u ? '#fff' : 'transparent',
|
|
||||||
color: unit === u ? 'primary.main' : alpha(timestampPageStyles.primaryColor, 0.4),
|
|
||||||
boxShadow: unit === u ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.toUpperCase()}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider
|
|
||||||
orientation="vertical"
|
|
||||||
flexItem
|
|
||||||
sx={{ mx: 0.5, my: 1, borderColor: alpha(timestampPageStyles.primaryColor, 0.1) }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={0.5}>
|
|
||||||
<Tooltip title="填充到下方">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={handleUseNow}
|
|
||||||
sx={{
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
bgcolor: '#fff',
|
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
|
||||||
'&:hover': { bgcolor: timestampPageStyles.primaryColor, color: '#fff' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AccessTimeIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<CopyButton
|
|
||||||
text={displayVal}
|
|
||||||
tooltip="复制时间戳"
|
|
||||||
size="small"
|
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LiveClock.displayName = 'LiveClock';
|
|
||||||
|
|
||||||
export default LiveClock;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import { Box, Checkbox, Typography } from '@mui/material';
|
|
||||||
import { formatSize } from '@/utils/storageCleaner';
|
|
||||||
import { storageCleanerPageStyles } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
interface OptionItemProps {
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
size?: number;
|
|
||||||
isCount?: boolean;
|
|
||||||
onChange: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OptionItem({
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
size,
|
|
||||||
isCount = false,
|
|
||||||
onChange,
|
|
||||||
}: OptionItemProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
py: 1,
|
|
||||||
px: 1.5,
|
|
||||||
borderRadius: 3,
|
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.05)' : 'transparent',
|
|
||||||
border: `1px solid ${checked ? 'rgba(255, 152, 0, 0.2)' : 'transparent'}`,
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: checked ? 'rgba(255, 152, 0, 0.1)' : 'rgba(0, 0, 0, 0.02)',
|
|
||||||
transform: 'translateY(-1px)',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0, mr: 1.5 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight={700}
|
|
||||||
color={checked ? storageCleanerPageStyles.warningColor : 'text.primary'}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
display: 'block',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
transition: 'color 0.2s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
{size !== undefined && size > 0 ? (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 600,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.3,
|
|
||||||
lineHeight: 1,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
opacity: 0.8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isCount ? `${size} 个` : formatSize(size)}
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'grey.400',
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
display: 'block',
|
|
||||||
mt: 0.3,
|
|
||||||
lineHeight: 1,
|
|
||||||
fontStyle: 'italic',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
无数据
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={checked}
|
|
||||||
onChange={onChange}
|
|
||||||
color="warning"
|
|
||||||
sx={{
|
|
||||||
p: 0.6,
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
fontSize: 18,
|
|
||||||
transition: 'transform 0.2s',
|
|
||||||
},
|
|
||||||
'&:hover .MuiSvgIcon-root': {
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import { Typography, Box, Fade, Stack, alpha } from '@mui/material';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import CopyButton from '@/components/CopyButton';
|
|
||||||
import { DATE_FORMAT, timestampPageStyles } from '@/config/pageTheme';
|
|
||||||
import type { UnitType } from '@/config/pageTheme';
|
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
interface ResultViewProps {
|
|
||||||
result: string;
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
unit: UnitType;
|
|
||||||
zone: string;
|
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ResultView = React.memo(({ result, mode, unit, zone, showMessage }: ResultViewProps) => {
|
|
||||||
const extraInfo = useMemo(() => {
|
|
||||||
if (!result) return null;
|
|
||||||
const d =
|
|
||||||
mode === 'ts2dt'
|
|
||||||
? dayjs(result, DATE_FORMAT).tz(zone)
|
|
||||||
: unit === 'ms'
|
|
||||||
? dayjs(Number(result))
|
|
||||||
: dayjs.unix(Number(result));
|
|
||||||
|
|
||||||
return {
|
|
||||||
relative: d.fromNow(),
|
|
||||||
iso: d.toISOString(),
|
|
||||||
utc: d.utc().format(DATE_FORMAT) + ' UTC',
|
|
||||||
};
|
|
||||||
}, [result, mode, zone, unit]);
|
|
||||||
|
|
||||||
if (!result) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fade in={!!result}>
|
|
||||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
color: 'text.secondary',
|
|
||||||
mb: 1.2,
|
|
||||||
display: 'block',
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: '0.7rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
转换结果
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
position: 'relative',
|
|
||||||
mb: 2.5,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="body1"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontWeight: 700,
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
pr: 4,
|
|
||||||
fontSize: '1rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result}
|
|
||||||
</Typography>
|
|
||||||
<CopyButton
|
|
||||||
text={result}
|
|
||||||
tooltip="复制结果"
|
|
||||||
size="small"
|
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
spacing={1.2}
|
|
||||||
sx={{
|
|
||||||
bgcolor: alpha(timestampPageStyles.primaryColor, 0.05),
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: alpha(timestampPageStyles.primaryColor, 0.1),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ label: '相对时间', value: extraInfo?.relative },
|
|
||||||
{ label: 'ISO 8601', value: extraInfo?.iso },
|
|
||||||
{ label: 'UTC 时间', value: extraInfo?.utc },
|
|
||||||
].map((item) => (
|
|
||||||
<Box
|
|
||||||
key={item.label}
|
|
||||||
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ color: 'text.disabled', fontWeight: 700, fontSize: '0.65rem', pr: 4 }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: timestampPageStyles.primaryColor,
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.65rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</Typography>
|
|
||||||
{item.value && (
|
|
||||||
<CopyButton
|
|
||||||
text={item.value}
|
|
||||||
tooltip="复制"
|
|
||||||
size="small"
|
|
||||||
color={timestampPageStyles.primaryColor}
|
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Fade>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ResultView.displayName = 'ResultView';
|
|
||||||
|
|
||||||
export default ResultView;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { Box, Checkbox, Divider, Grid, Typography } from '@mui/material';
|
|
||||||
import type { StorageCleanerOptions } from '@/types/storage';
|
|
||||||
import OptionItem from './OptionItem';
|
|
||||||
|
|
||||||
interface StorageOptionsGridProps {
|
|
||||||
options: StorageCleanerOptions;
|
|
||||||
sizes: Record<string, number>;
|
|
||||||
allSelected: boolean;
|
|
||||||
someSelected: boolean;
|
|
||||||
onOptionChange: (key: keyof StorageCleanerOptions) => void;
|
|
||||||
onSelectAll: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StorageOptionsGrid({
|
|
||||||
options,
|
|
||||||
sizes,
|
|
||||||
allSelected,
|
|
||||||
someSelected,
|
|
||||||
onOptionChange,
|
|
||||||
onSelectAll,
|
|
||||||
}: StorageOptionsGridProps) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mb: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'grey.100',
|
|
||||||
borderRadius: 4,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
overflow: 'hidden',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box sx={{ p: 1.2 }}>
|
|
||||||
<Grid container spacing={1.5}>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="LocalStorage"
|
|
||||||
checked={options.localStorage}
|
|
||||||
size={sizes.localStorage}
|
|
||||||
onChange={() => onOptionChange('localStorage')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Session Storage"
|
|
||||||
checked={options.sessionStorage}
|
|
||||||
size={sizes.sessionStorage}
|
|
||||||
onChange={() => onOptionChange('sessionStorage')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="IndexedDB"
|
|
||||||
checked={options.indexedDB}
|
|
||||||
size={sizes.indexedDB}
|
|
||||||
onChange={() => onOptionChange('indexedDB')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Cookies"
|
|
||||||
checked={options.cookies}
|
|
||||||
size={sizes.cookies}
|
|
||||||
onChange={() => onOptionChange('cookies')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Cache Storage"
|
|
||||||
checked={options.cacheStorage}
|
|
||||||
size={sizes.cacheStorage}
|
|
||||||
isCount
|
|
||||||
onChange={() => onOptionChange('cacheStorage')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={6}>
|
|
||||||
<OptionItem
|
|
||||||
label="Service Workers"
|
|
||||||
checked={options.serviceWorkers}
|
|
||||||
size={sizes.serviceWorkers}
|
|
||||||
isCount
|
|
||||||
onChange={() => onOptionChange('serviceWorkers')}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
px: 2.7,
|
|
||||||
py: 0.8,
|
|
||||||
borderBottomLeftRadius: 4,
|
|
||||||
borderBottomRightRadius: 4,
|
|
||||||
transition: 'all 0.2s',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
fontWeight={700}
|
|
||||||
sx={{ color: 'text.secondary', fontSize: '0.7rem', px: 0 }}
|
|
||||||
>
|
|
||||||
全选所有项
|
|
||||||
</Typography>
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={allSelected}
|
|
||||||
indeterminate={someSelected}
|
|
||||||
onChange={(e) => onSelectAll(e.target.checked)}
|
|
||||||
color="warning"
|
|
||||||
sx={{
|
|
||||||
p: 0.6,
|
|
||||||
mr: 0,
|
|
||||||
'& .MuiSvgIcon-root': {
|
|
||||||
fontSize: 18,
|
|
||||||
transition: 'transform 0.2s',
|
|
||||||
},
|
|
||||||
'&:hover .MuiSvgIcon-root': {
|
|
||||||
transform: 'scale(1.1)',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { getActiveTabDomain } from '@/utils/chromeTabs';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动获取并维护当前活动标签页域名的 Hook
|
|
||||||
*/
|
|
||||||
export function useActiveTabDomain() {
|
|
||||||
const [domain, setDomain] = useState<string>('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getActiveTabDomain().then(setDomain);
|
|
||||||
|
|
||||||
// 如果需要实时同步(如切换标签页),可以监听 chrome.tabs.onActivated
|
|
||||||
const handleActivated = () => getActiveTabDomain().then(setDomain);
|
|
||||||
const handleUpdated = (_: number, changeInfo: { url?: string }) => {
|
|
||||||
if (changeInfo.url) getActiveTabDomain().then(setDomain);
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.tabs.onActivated.addListener(handleActivated);
|
|
||||||
chrome.tabs.onUpdated.addListener(handleUpdated);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
chrome.tabs.onActivated.removeListener(handleActivated);
|
|
||||||
chrome.tabs.onUpdated.removeListener(handleUpdated);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return domain;
|
|
||||||
}
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
import { useState, useRef, useCallback } from 'react';
|
|
||||||
import { useSnackbar as useGlobalSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
import { MessageAction, sendMessageToContent, injectContentScript } from '@/utils/messages';
|
|
||||||
import { FillMode } from '@/utils/dummyDataGenerator';
|
|
||||||
import { useStorageState } from '@/utils/useStorageState';
|
|
||||||
import { FieldTypePreferences } from '@/types/storage';
|
|
||||||
import { useActiveTabDomain } from './useActiveTabDomain';
|
|
||||||
import { useSidePanelState } from './useSidePanelState';
|
|
||||||
|
|
||||||
// 字段数据接口
|
|
||||||
export interface FieldData {
|
|
||||||
id: string;
|
|
||||||
fieldType: string;
|
|
||||||
label: string | null;
|
|
||||||
placeholder: string;
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
isSelected: boolean;
|
|
||||||
generatedValue: string;
|
|
||||||
useInvalidData?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_FIELD_TYPE_PREFERENCES: FieldTypePreferences = {};
|
|
||||||
|
|
||||||
export function useFormRecognizer() {
|
|
||||||
const { showMessage } = useGlobalSnackbar({ autoHideDuration: 1500 });
|
|
||||||
const [fillLoading, setFillLoading] = useState(false);
|
|
||||||
const [clearLoading, setClearLoading] = useState(false);
|
|
||||||
const [includeHidden, setIncludeHidden] = useState(false);
|
|
||||||
const isProcessingRef = useRef(false);
|
|
||||||
const [fields, setFields] = useState<FieldData[]>([]);
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [showFields, setShowFields] = useState(false);
|
|
||||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const currentDomain = useActiveTabDomain();
|
|
||||||
const { sidePanelOpen, handleOpenSidePanel } = useSidePanelState();
|
|
||||||
|
|
||||||
const [fieldTypePreferences, setFieldTypePreferences] = useStorageState(
|
|
||||||
'formRecognizer/fieldTypePreferences',
|
|
||||||
DEFAULT_FIELD_TYPE_PREFERENCES,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 生成字段标识符
|
|
||||||
const getFieldIdentifier = useCallback(
|
|
||||||
(field: Pick<FieldData, 'label' | 'name' | 'placeholder'>): string => {
|
|
||||||
return field.label || field.name || field.placeholder || 'unknown';
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 应用保存的类型偏好
|
|
||||||
const applySavedPreferences = useCallback(
|
|
||||||
(fields: FieldData[], domain: string, preferences: FieldTypePreferences): FieldData[] => {
|
|
||||||
if (!domain || !preferences[domain]) {
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
const prefs = preferences[domain];
|
|
||||||
return fields.map((field) => {
|
|
||||||
const identifier = getFieldIdentifier(field);
|
|
||||||
if (prefs && prefs[identifier]) {
|
|
||||||
return { ...field, fieldType: prefs[identifier] };
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[getFieldIdentifier],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 保存类型偏好
|
|
||||||
const saveTypePreference = useCallback(
|
|
||||||
(field: FieldData, newType: string) => {
|
|
||||||
if (!currentDomain) return;
|
|
||||||
const identifier = getFieldIdentifier(field);
|
|
||||||
setFieldTypePreferences((prev) => {
|
|
||||||
const prevPrefs = prev as FieldTypePreferences;
|
|
||||||
const currentDomainPrefs = prevPrefs[currentDomain] || {};
|
|
||||||
return {
|
|
||||||
...prevPrefs,
|
|
||||||
[currentDomain]: {
|
|
||||||
...currentDomainPrefs,
|
|
||||||
[identifier]: newType,
|
|
||||||
},
|
|
||||||
} as FieldTypePreferences;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[currentDomain, getFieldIdentifier, setFieldTypePreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 扫描表单字段
|
|
||||||
const handleScanFields = async () => {
|
|
||||||
setScanning(true);
|
|
||||||
try {
|
|
||||||
let response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
|
||||||
|
|
||||||
if (!response.success && response.message && response.message.includes('无法连接')) {
|
|
||||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
|
||||||
const injected = await injectContentScript();
|
|
||||||
if (injected) {
|
|
||||||
response = await sendMessageToContent(MessageAction.SCAN_FORM_FIELDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success && response.fields) {
|
|
||||||
const fieldsWithPreferences = applySavedPreferences(
|
|
||||||
response.fields as FieldData[],
|
|
||||||
currentDomain,
|
|
||||||
fieldTypePreferences,
|
|
||||||
);
|
|
||||||
setFields(fieldsWithPreferences);
|
|
||||||
setShowFields(true);
|
|
||||||
showMessage(`扫描完成,发现 ${response.totalCount} 个可填充字段`, { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
showMessage(response.message || '扫描失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('扫描失败:', error);
|
|
||||||
showMessage('扫描失败,请确保页面已加载', { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 更新字段类型
|
|
||||||
const handleFieldTypeChange = (fieldId: string, newType: string) => {
|
|
||||||
setFields((prev) =>
|
|
||||||
prev.map((field) => {
|
|
||||||
if (field.id === fieldId) {
|
|
||||||
const updatedField = {
|
|
||||||
...field,
|
|
||||||
fieldType: newType,
|
|
||||||
generatedValue: '',
|
|
||||||
};
|
|
||||||
saveTypePreference(field, newType);
|
|
||||||
return updatedField;
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换单个字段的选中状态
|
|
||||||
const handleToggleFieldSelection = (fieldId: string) => {
|
|
||||||
setFields((prev) =>
|
|
||||||
prev.map((field) =>
|
|
||||||
field.id === fieldId ? { ...field, isSelected: !field.isSelected } : field,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 全选/取消全选
|
|
||||||
const handleToggleAllFields = () => {
|
|
||||||
setFields((prev) => {
|
|
||||||
const allSelected = prev.every((f) => f.isSelected);
|
|
||||||
return prev.map((f) => ({ ...f, isSelected: !allSelected }));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 定位字段(闪烁)
|
|
||||||
const handleLocateField = async (fieldId: string) => {
|
|
||||||
try {
|
|
||||||
const response = await sendMessageToContent(MessageAction.FLASH_FIELD, { fieldId });
|
|
||||||
if (!response.success) {
|
|
||||||
showMessage(response.message || '定位字段失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('定位字段失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 悬停高亮
|
|
||||||
const handleHoverField = async (fieldId: string | null) => {
|
|
||||||
setHoveredFieldId(fieldId);
|
|
||||||
try {
|
|
||||||
if (fieldId) {
|
|
||||||
await sendMessageToContent(MessageAction.HIGHLIGHT_FIELD, { fieldId });
|
|
||||||
} else {
|
|
||||||
await sendMessageToContent(MessageAction.UNHIGHLIGHT_ALL_FIELDS);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('高亮字段失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 填充选中字段
|
|
||||||
const handleFillSelectedFields = async () => {
|
|
||||||
if (isProcessingRef.current) {
|
|
||||||
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCount = fields.filter((f) => f.isSelected).length;
|
|
||||||
if (selectedCount === 0) {
|
|
||||||
showMessage('请先选择要填充的字段', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFillLoading(true);
|
|
||||||
isProcessingRef.current = true;
|
|
||||||
try {
|
|
||||||
const messageFields = fields as MessageFieldData[];
|
|
||||||
let response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
|
||||||
fields: messageFields,
|
|
||||||
mode: FillMode.VALID,
|
|
||||||
includeHidden,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.success && response.message && response.message.includes('无法连接')) {
|
|
||||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
|
||||||
const injected = await injectContentScript();
|
|
||||||
if (injected) {
|
|
||||||
response = await sendMessageToContent(MessageAction.FILL_SELECTED_FIELDS, {
|
|
||||||
fields: messageFields,
|
|
||||||
mode: FillMode.VALID,
|
|
||||||
includeHidden,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
showMessage(response.message || '填充成功', { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
showMessage(response.message || '填充失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('填充失败:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
|
||||||
showMessage(`填充失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setFillLoading(false);
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 清空所有字段
|
|
||||||
const handleClearAllFields = async () => {
|
|
||||||
if (isProcessingRef.current) {
|
|
||||||
showMessage('操作进行中,请稍候...', { severity: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setClearLoading(true);
|
|
||||||
isProcessingRef.current = true;
|
|
||||||
try {
|
|
||||||
let response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
|
|
||||||
|
|
||||||
if (!response.success && response.message && response.message.includes('无法连接')) {
|
|
||||||
showMessage('正在注入内容脚本...', { severity: 'info' });
|
|
||||||
const injected = await injectContentScript();
|
|
||||||
if (injected) {
|
|
||||||
response = await sendMessageToContent(MessageAction.CLEAR_ALL_FIELDS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
showMessage(response.message || '清空成功', { severity: 'success' });
|
|
||||||
} else {
|
|
||||||
showMessage(response.message || '清空失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清空失败:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : '未知错误';
|
|
||||||
showMessage(`清空失败:${errorMessage},请确保当前页面已加载完成`, { severity: 'error' });
|
|
||||||
} finally {
|
|
||||||
setClearLoading(false);
|
|
||||||
isProcessingRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
fillLoading,
|
|
||||||
clearLoading,
|
|
||||||
includeHidden,
|
|
||||||
setIncludeHidden,
|
|
||||||
fields,
|
|
||||||
scanning,
|
|
||||||
showFields,
|
|
||||||
setShowFields,
|
|
||||||
hoveredFieldId,
|
|
||||||
sidePanelOpen,
|
|
||||||
handleScanFields,
|
|
||||||
handleFieldTypeChange,
|
|
||||||
handleToggleFieldSelection,
|
|
||||||
handleToggleAllFields,
|
|
||||||
handleLocateField,
|
|
||||||
handleHoverField,
|
|
||||||
handleFillSelectedFields,
|
|
||||||
handleClearAllFields,
|
|
||||||
handleOpenSidePanel,
|
|
||||||
selectedCount: fields.filter((f) => f.isSelected).length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { MessageAction, onMessage } from '@/utils/messages';
|
|
||||||
import { useSnackbar } from '@/components/SnackbarProvider';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理侧边栏状态检测与开启逻辑的 Hook
|
|
||||||
*/
|
|
||||||
export function useSidePanelState() {
|
|
||||||
const [sidePanelOpen, setSidePanelOpen] = useState(false);
|
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
|
|
||||||
const checkSidePanelState = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (typeof chrome.runtime.getContexts === 'function') {
|
|
||||||
const contexts = await chrome.runtime.getContexts({
|
|
||||||
contextTypes: ['SIDE_PANEL'],
|
|
||||||
});
|
|
||||||
setSidePanelOpen(contexts.length > 0);
|
|
||||||
} else {
|
|
||||||
setSidePanelOpen(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('检测侧边栏状态失败:', error);
|
|
||||||
setSidePanelOpen(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 使用 requestAnimationFrame 避免同步调用 setState
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
checkSidePanelState().catch(console.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 监听侧边栏状态变化消息
|
|
||||||
const removeListener = onMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, (message) => {
|
|
||||||
setSidePanelOpen(message.data.isOpen);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
removeListener();
|
|
||||||
};
|
|
||||||
}, [checkSidePanelState]);
|
|
||||||
|
|
||||||
const handleOpenSidePanel = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await chrome.sidePanel.open({ windowId: chrome.windows.WINDOW_ID_CURRENT });
|
|
||||||
setSidePanelOpen(true);
|
|
||||||
showMessage('侧边栏已打开', { severity: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('打开侧边栏失败:', error);
|
|
||||||
showMessage('打开侧边栏失败', { severity: 'error' });
|
|
||||||
}
|
|
||||||
}, [showMessage]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sidePanelOpen,
|
|
||||||
handleOpenSidePanel,
|
|
||||||
checkSidePanelState,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import dayjs from '@/utils/dayjs';
|
|
||||||
import { DATE_FORMAT } from '@/config/pageTheme';
|
|
||||||
import type { UnitType, ZoneType } from '@/config/pageTheme';
|
|
||||||
|
|
||||||
export interface UseTimestampConverterReturn {
|
|
||||||
// State
|
|
||||||
mode: 'ts2dt' | 'dt2ts';
|
|
||||||
tsInput: string;
|
|
||||||
dtInput: string;
|
|
||||||
unit: UnitType;
|
|
||||||
zone: ZoneType;
|
|
||||||
result: string;
|
|
||||||
error: string;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setMode: (mode: 'ts2dt' | 'dt2ts') => void;
|
|
||||||
setTsInput: (value: string) => void;
|
|
||||||
setDtInput: (value: string) => void;
|
|
||||||
setUnit: (unit: UnitType) => void;
|
|
||||||
setZone: (zone: ZoneType) => void;
|
|
||||||
handleUseNow: (now: number) => void;
|
|
||||||
convert: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTimestampConverter(): UseTimestampConverterReturn {
|
|
||||||
const [mode, setMode] = useState<'ts2dt' | 'dt2ts'>('ts2dt');
|
|
||||||
const [tsInput, setTsInput] = useState(() => String(Date.now()));
|
|
||||||
const [dtInput, setDtInput] = useState(() => dayjs().format(DATE_FORMAT));
|
|
||||||
const [unit, setUnit] = useState<UnitType>('ms');
|
|
||||||
const [zone, setZone] = useState<ZoneType>('Asia/Shanghai');
|
|
||||||
const [result, setResult] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const convert = useCallback(() => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
const rawInput = tsInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const num = Number(rawInput);
|
|
||||||
if (isNaN(num)) {
|
|
||||||
setError('无效数字');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d = unit === 'ms' ? dayjs(num) : dayjs.unix(num);
|
|
||||||
if (!d.isValid()) {
|
|
||||||
setError('无效时间戳');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
setResult(d.tz(zone).format(DATE_FORMAT));
|
|
||||||
} else {
|
|
||||||
const rawInput = dtInput.trim();
|
|
||||||
if (!rawInput) return;
|
|
||||||
const d = dayjs.tz(rawInput, DATE_FORMAT, zone);
|
|
||||||
if (!d.isValid()) {
|
|
||||||
setError('格式错误');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError('');
|
|
||||||
const ms = d.valueOf();
|
|
||||||
setResult(unit === 'ms' ? String(ms) : String(Math.floor(ms / 1000)));
|
|
||||||
}
|
|
||||||
}, [mode, tsInput, dtInput, unit, zone]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(convert, 400);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [convert]);
|
|
||||||
|
|
||||||
const handleUseNow = useCallback(
|
|
||||||
(now: number) => {
|
|
||||||
if (mode === 'ts2dt') {
|
|
||||||
setTsInput(String(unit === 'ms' ? now : Math.floor(now / 1000)));
|
|
||||||
} else {
|
|
||||||
setDtInput(dayjs(now).tz(zone).format(DATE_FORMAT));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mode, unit, zone],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSetMode = useCallback((newMode: 'ts2dt' | 'dt2ts') => {
|
|
||||||
setMode(newMode);
|
|
||||||
setError('');
|
|
||||||
setResult('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSetTsInput = useCallback((value: string) => {
|
|
||||||
setTsInput(value);
|
|
||||||
setError('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSetDtInput = useCallback((value: string) => {
|
|
||||||
setDtInput(value);
|
|
||||||
setError('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode,
|
|
||||||
tsInput,
|
|
||||||
dtInput,
|
|
||||||
unit,
|
|
||||||
zone,
|
|
||||||
result,
|
|
||||||
error,
|
|
||||||
setMode: handleSetMode,
|
|
||||||
setTsInput: handleSetTsInput,
|
|
||||||
setDtInput: handleSetDtInput,
|
|
||||||
setUnit,
|
|
||||||
setZone,
|
|
||||||
handleUseNow,
|
|
||||||
convert,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/config/theme';
|
|
||||||
import App from './App.tsx';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<App />
|
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -4,52 +4,67 @@ import reactHooks from 'eslint-plugin-react-hooks';
|
|||||||
import reactPlugin from 'eslint-plugin-react';
|
import reactPlugin from 'eslint-plugin-react';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
|
|
||||||
export default [
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: ['dist', '.output', '.wxt', 'node_modules', 'eslint.config.ts', 'eslint.config.js'],
|
||||||
'dist',
|
|
||||||
'.wxt',
|
|
||||||
'node_modules',
|
|
||||||
'eslint.config.ts',
|
|
||||||
'**/*.test.tsx',
|
|
||||||
'**/*.test.ts',
|
|
||||||
'**/__tests__/**',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
{
|
{
|
||||||
files: [
|
files: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}', 'setupTests.ts'],
|
||||||
'hooks/**/*.{ts,tsx}',
|
rules: {
|
||||||
'entrypoints/**/*.{ts,tsx}',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'pages/**/*.{ts,tsx}',
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'utils/**/*.{ts,tsx}',
|
'warn',
|
||||||
'components/**/*.{ts,tsx}',
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
'services/**/*.{ts,tsx}',
|
],
|
||||||
],
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
ignores: ['**/__tests__/**', '**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
||||||
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2022, // 💡 升级至现代高频语法解析
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node,
|
||||||
},
|
},
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ['./tsconfig.json'],
|
projectService: true,
|
||||||
tsconfigRootDir: import.meta.dirname,
|
tsconfigRootDir: import.meta.dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
plugins: {
|
plugins: {
|
||||||
react: reactPlugin as any,
|
react: reactPlugin,
|
||||||
'react-hooks': reactHooks as any,
|
'react-hooks': reactHooks,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: 'detect',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
|
...reactPlugin.configs.recommended.rules,
|
||||||
|
...reactPlugin.configs['jsx-runtime'].rules,
|
||||||
|
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
|
||||||
'no-unused-vars': 'off',
|
'no-unused-vars': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
],
|
],
|
||||||
|
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
);
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
|
/* global process */
|
||||||
|
|
||||||
|
const isCI = process.env.CI === 'true';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// 对于代码文件:
|
|
||||||
'*.{ts,tsx,js,jsx,mjs}': [
|
'*.{ts,tsx,js,jsx,mjs}': [
|
||||||
// 1. Prettier: 全局格式化
|
'eslint --fix --no-warn-ignored',
|
||||||
|
...(isCI ? ['eslint --max-warnings=0'] : []),
|
||||||
'prettier --write',
|
'prettier --write',
|
||||||
|
|
||||||
// 2. ESLint: 检查并自动修复
|
|
||||||
// --no-warn-ignored: 抑制对忽略文件的警告(eslint.config.ts 中忽略了测试文件)
|
|
||||||
'eslint --fix --max-warnings=0 --no-warn-ignored',
|
|
||||||
|
|
||||||
// 3. TypeScript: 类型检查
|
|
||||||
() => 'tsc --noEmit',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// 对于其他文件:
|
|
||||||
'*.{json,css,scss,md}': ['prettier --write'],
|
'*.{json,css,scss,md}': ['prettier --write'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,51 +12,65 @@
|
|||||||
"build:firefox": "wxt build -b firefox",
|
"build:firefox": "wxt build -b firefox",
|
||||||
"zip": "wxt zip",
|
"zip": "wxt zip",
|
||||||
"zip:firefox": "wxt zip -b firefox",
|
"zip:firefox": "wxt zip -b firefox",
|
||||||
"compile": "tsc --noEmit",
|
|
||||||
"postinstall": "wxt prepare",
|
"postinstall": "wxt prepare",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@faker-js/faker": "^10.4.0",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@mui/icons-material": "^7.3.8",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@mui/material": "^7.3.8",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@webext-core/messaging": "^2.3.0",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"dayjs": "^1.11.19",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"jsqr": "^1.4.0",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"qrcode": "^1.5.4",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"react": "^19.2.3",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"react-dom": "^19.2.3"
|
"@vitest/coverage-v8": "^4.1.7",
|
||||||
|
"@webext-core/messaging": "^3.0.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"qr-scanner": "^1.4.2",
|
||||||
|
"qrious": "^4.0.2",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.0",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.0.0",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chrome": "^0.1.36",
|
"@types/chrome": "^0.1.42",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/webextension-polyfill": "^0.12.4",
|
"@types/webextension-polyfill": "^0.12.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.59.4",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.59.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@wxt-dev/module-react": "^1.1.5",
|
"@wxt-dev/module-react": "^1.2.2",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"globals": "^17.2.0",
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^25.0.0",
|
"jsdom": "^29.1.1",
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^17.0.5",
|
||||||
"prettier": "^3.8.1",
|
"postcss": "^8.5.15",
|
||||||
"terser": "^5.46.0",
|
"prettier": "^3.8.3",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"terser": "^5.47.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.59.4",
|
||||||
"vitest": "^2.0.0",
|
"vitest": "^4.1.7",
|
||||||
"wxt": "^0.20.6"
|
"wxt": "^0.20.26"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import tailwindcss from 'tailwindcss';
|
||||||
|
import autoprefixer from 'autoprefixer';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: [tailwindcss, autoprefixer],
|
||||||
|
};
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
|
||||||
import type { PageType, StorageSchema } from '@/types/storage';
|
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
|
||||||
import {
|
|
||||||
getDefaultVisibleFeatureKeys,
|
|
||||||
getDefaultPageOrder,
|
|
||||||
getAllFeatureKeys,
|
|
||||||
} from '@/config/features';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验是否为合法的页面类型
|
|
||||||
*/
|
|
||||||
const isValidPage = (page: unknown): page is PageType => {
|
|
||||||
return typeof page === 'string' && (getAllFeatureKeys() as string[]).includes(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验页面列表是否合法(所有项都必须是合法的 PageType)
|
|
||||||
*/
|
|
||||||
const isValidPageList = (pages: unknown): pages is PageType[] => {
|
|
||||||
return Array.isArray(pages) && pages.every(isValidPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路由上下文类型定义
|
|
||||||
*/
|
|
||||||
interface RouterContextType {
|
|
||||||
/** 当前所在页面 */
|
|
||||||
currentPage: PageType;
|
|
||||||
/** 当前可见的页面列表(用于侧边栏/菜单显示) */
|
|
||||||
visiblePages: PageType[];
|
|
||||||
/** 页面显示顺序 */
|
|
||||||
pageOrder: PageType[];
|
|
||||||
/** 路由数据是否已从存储中加载完成 */
|
|
||||||
isLoaded: boolean;
|
|
||||||
/** 导航到指定页面 */
|
|
||||||
navigateTo: (page: PageType) => void;
|
|
||||||
/** 仅在本地(当前组件状态)跳转,不影响其他同步端 */
|
|
||||||
navigateLocal: (page: PageType) => void;
|
|
||||||
/** 强制同步当前路由到存储 */
|
|
||||||
syncNavigation: (page: PageType) => void;
|
|
||||||
/** 返回仪表盘 */
|
|
||||||
goBack: () => void;
|
|
||||||
/** 设置可见页面列表 */
|
|
||||||
setVisiblePages: (pages: PageType[]) => void;
|
|
||||||
/** 设置页面显示顺序 */
|
|
||||||
setPageOrder: (pages: PageType[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建路由上下文
|
|
||||||
*/
|
|
||||||
const RouterContext = createContext<RouterContextType | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路由提供者组件参数类型
|
|
||||||
*/
|
|
||||||
interface RouterProviderProps {
|
|
||||||
/** 子组件 */
|
|
||||||
children: ReactNode;
|
|
||||||
/** 默认初始路由,默认为 'dashboard' */
|
|
||||||
defaultRoute?: PageType;
|
|
||||||
/** 是否同步路由状态到存储,默认为 true */
|
|
||||||
syncRoute?: boolean;
|
|
||||||
/** 存储路由状态的键名,默认为 'app/currentRoute' */
|
|
||||||
syncKey?: keyof StorageSchema;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 同步从 localStorage 获取存储快照(用于消除异步加载产生的首屏闪烁)
|
|
||||||
*/
|
|
||||||
const getSyncSnapshot = <T,>(
|
|
||||||
key: string,
|
|
||||||
defaultValue: T,
|
|
||||||
validator?: (val: unknown) => val is T,
|
|
||||||
): T => {
|
|
||||||
try {
|
|
||||||
const val = localStorage.getItem(`snapshot/${key}`);
|
|
||||||
if (!val) return defaultValue;
|
|
||||||
const parsed = JSON.parse(val) as unknown;
|
|
||||||
if (validator) {
|
|
||||||
return validator(parsed) ? parsed : defaultValue;
|
|
||||||
}
|
|
||||||
return (parsed as T) ?? defaultValue;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('解析同步快照失败:', error);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 路由提供者组件
|
|
||||||
* 负责管理应用内部的路由状态、页面可见性及排序,并支持与 Chrome Storage 同步
|
|
||||||
*/
|
|
||||||
export function RouterProvider({
|
|
||||||
children,
|
|
||||||
defaultRoute = 'dashboard',
|
|
||||||
syncRoute = true,
|
|
||||||
syncKey = 'app/currentRoute',
|
|
||||||
}: RouterProviderProps) {
|
|
||||||
// 当前页面状态:优先从同步快照加载,并进行合法性校验
|
|
||||||
const [currentPage, setCurrentPage] = useState<PageType>(() =>
|
|
||||||
getSyncSnapshot(syncKey as string, defaultRoute, isValidPage),
|
|
||||||
);
|
|
||||||
// 可见页面列表状态
|
|
||||||
const [visiblePages, setVisiblePages] = useState<PageType[]>(() =>
|
|
||||||
getSyncSnapshot('app/visiblePages', getDefaultVisibleFeatureKeys(), isValidPageList),
|
|
||||||
);
|
|
||||||
// 页面排序状态
|
|
||||||
const [pageOrder, setPageOrder] = useState<PageType[]>(() =>
|
|
||||||
getSyncSnapshot('app/pageOrder', getDefaultPageOrder(), isValidPageList),
|
|
||||||
);
|
|
||||||
// 加载完成标识
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
/**
|
|
||||||
* 从存储中加载初始路由数据
|
|
||||||
*/
|
|
||||||
const loadInitialData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const savedRoute = await storageUtil.get(syncKey, defaultRoute);
|
|
||||||
const savedVisiblePages = await storageUtil.get(
|
|
||||||
'app/visiblePages',
|
|
||||||
getDefaultVisibleFeatureKeys(),
|
|
||||||
);
|
|
||||||
const savedPageOrder = await storageUtil.get('app/pageOrder', getDefaultPageOrder());
|
|
||||||
|
|
||||||
// 增加数据合法性校验并进行类型收窄
|
|
||||||
if (isValidPage(savedRoute) && syncRoute) {
|
|
||||||
setCurrentPage(savedRoute);
|
|
||||||
}
|
|
||||||
if (isValidPageList(savedVisiblePages)) {
|
|
||||||
setVisiblePages(savedVisiblePages);
|
|
||||||
}
|
|
||||||
if (isValidPageList(savedPageOrder) && savedPageOrder.length > 0) {
|
|
||||||
setPageOrder(savedPageOrder);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载初始路由数据失败:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoaded(true);
|
|
||||||
}
|
|
||||||
}, [defaultRoute, syncKey, syncRoute]);
|
|
||||||
|
|
||||||
// 组件挂载时加载初始数据
|
|
||||||
useEffect(() => {
|
|
||||||
loadInitialData().catch(console.error);
|
|
||||||
}, [loadInitialData]);
|
|
||||||
|
|
||||||
// 当 currentPage 改变时,如果开启了同步,则持久化到存储和本地快照
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded && syncRoute) {
|
|
||||||
storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
|
|
||||||
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
|
|
||||||
}
|
|
||||||
}, [currentPage, isLoaded, syncRoute, syncKey]);
|
|
||||||
|
|
||||||
// 持久化可见页面列表
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded) {
|
|
||||||
storageUtil.set('app/visiblePages', visiblePages).catch(console.error);
|
|
||||||
localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(visiblePages));
|
|
||||||
}
|
|
||||||
}, [visiblePages, isLoaded]);
|
|
||||||
|
|
||||||
// 持久化页面排序
|
|
||||||
useEffect(() => {
|
|
||||||
if (isLoaded) {
|
|
||||||
storageUtil.set('app/pageOrder', pageOrder).catch(console.error);
|
|
||||||
localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(pageOrder));
|
|
||||||
}
|
|
||||||
}, [pageOrder, isLoaded]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 监听存储变化,以便在多个入口(如 Popup 和 Options)之间同步路由和设置
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (!syncRoute) return;
|
|
||||||
|
|
||||||
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
|
||||||
// 同步当前路由
|
|
||||||
if (syncRoute && changes[syncKey as string]) {
|
|
||||||
const newRoute = changes[syncKey as string].newValue as PageType;
|
|
||||||
if (newRoute && newRoute !== currentPage && isValidPage(newRoute)) {
|
|
||||||
setCurrentPage(newRoute);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 同步可见页面列表
|
|
||||||
if (changes['app/visiblePages']) {
|
|
||||||
const newPages = changes['app/visiblePages'].newValue;
|
|
||||||
if (isValidPageList(newPages)) {
|
|
||||||
setVisiblePages(newPages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 同步页面排序
|
|
||||||
if (changes['app/pageOrder']) {
|
|
||||||
const newOrder = changes['app/pageOrder'].newValue;
|
|
||||||
if (isValidPageList(newOrder)) {
|
|
||||||
setPageOrder(newOrder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
chrome.storage.onChanged.addListener(handleStorageChange);
|
|
||||||
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
|
||||||
}, [syncRoute, currentPage, syncKey]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 跳转到指定页面
|
|
||||||
*/
|
|
||||||
const navigateTo = (page: PageType) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 仅在本地跳转,不触发自动同步(通常由 handleStorageChange 内部调用)
|
|
||||||
*/
|
|
||||||
const navigateLocal = (page: PageType) => {
|
|
||||||
setCurrentPage(page);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 手动同步导航状态到存储
|
|
||||||
*/
|
|
||||||
const syncNavigation = (page: PageType) => {
|
|
||||||
storageUtil.set(syncKey, page as StorageSchema[typeof syncKey]).catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返回主仪表盘
|
|
||||||
*/
|
|
||||||
const goBack = () => {
|
|
||||||
setCurrentPage('dashboard');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RouterContext.Provider
|
|
||||||
value={{
|
|
||||||
currentPage,
|
|
||||||
visiblePages,
|
|
||||||
pageOrder,
|
|
||||||
isLoaded,
|
|
||||||
navigateTo,
|
|
||||||
navigateLocal,
|
|
||||||
syncNavigation,
|
|
||||||
goBack,
|
|
||||||
setVisiblePages,
|
|
||||||
setPageOrder,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</RouterContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义 Hook:获取路由上下文
|
|
||||||
* @throws {Error} 如果在 RouterProvider 之外使用则抛出异常
|
|
||||||
*/
|
|
||||||
export function useRouter() {
|
|
||||||
const context = useContext(RouterContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useRouter must be used within a RouterProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RouterProvider;
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# public/
|
||||||
|
|
||||||
|
静态资源目录,存放无需构建处理的文件,会被直接复制到输出目录。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件/目录 | 用途 |
|
||||||
|
| -------------- | -------------------------------- |
|
||||||
|
| `icon/` | 扩展图标,提供多种尺寸 |
|
||||||
|
| `icon/16.png` | 16×16 图标(工具栏) |
|
||||||
|
| `icon/32.png` | 32×32 图标 |
|
||||||
|
| `icon/48.png` | 48×48 图标(扩展管理页) |
|
||||||
|
| `icon/96.png` | 96×96 图标 |
|
||||||
|
| `icon/128.png` | 128×128 图标(Chrome Web Store) |
|
||||||
|
| `wxt.svg` | WXT 框架标志 SVG 图标 |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
||||||
|
- 图标格式推荐使用 PNG,确保透明背景
|
||||||
@@ -0,0 +1,924 @@
|
|||||||
|
{
|
||||||
|
"buttons_copy": {
|
||||||
|
"message": "复制",
|
||||||
|
"description": "Translation key: buttons_copy"
|
||||||
|
},
|
||||||
|
"buttons_clear": {
|
||||||
|
"message": "清理",
|
||||||
|
"description": "Translation key: buttons_clear"
|
||||||
|
},
|
||||||
|
|
||||||
|
"messages_copySuccess": {
|
||||||
|
"message": "已复制到剪贴板",
|
||||||
|
"description": "Translation key: messages_copySuccess"
|
||||||
|
},
|
||||||
|
"messages_copyError": {
|
||||||
|
"message": "复制失败",
|
||||||
|
"description": "Translation key: messages_copyError"
|
||||||
|
},
|
||||||
|
"messages_copyEmpty": {
|
||||||
|
"message": "无内容可复制",
|
||||||
|
"description": "Translation key: messages_copyEmpty"
|
||||||
|
},
|
||||||
|
"errorBoundary_title": {
|
||||||
|
"message": "糟糕,出了点问题",
|
||||||
|
"description": "Translation key: errorBoundary_title"
|
||||||
|
},
|
||||||
|
"errorBoundary_description": {
|
||||||
|
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||||
|
"description": "Translation key: errorBoundary_description"
|
||||||
|
},
|
||||||
|
"errorBoundary_refresh": {
|
||||||
|
"message": "刷新应用",
|
||||||
|
"description": "Translation key: errorBoundary_refresh"
|
||||||
|
},
|
||||||
|
"errorBoundary_retry": {
|
||||||
|
"message": "重新尝试",
|
||||||
|
"description": "Translation key: errorBoundary_retry"
|
||||||
|
},
|
||||||
|
"pageErrorBoundary_title": {
|
||||||
|
"message": "该功能运行异常",
|
||||||
|
"description": "Translation key: pageErrorBoundary_title"
|
||||||
|
},
|
||||||
|
"pageErrorBoundary_description": {
|
||||||
|
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
|
||||||
|
"description": "Translation key: pageErrorBoundary_description"
|
||||||
|
},
|
||||||
|
"router_notFound": {
|
||||||
|
"message": "页面未找到",
|
||||||
|
"description": "Translation key: router_notFound"
|
||||||
|
},
|
||||||
|
"router_notFoundDescription": {
|
||||||
|
"message": "该功能在当前运行环境({{entryPointType}})下不可用或已被移除。",
|
||||||
|
"description": "Translation key: router_notFoundDescription"
|
||||||
|
},
|
||||||
|
"textInputArea_clear": {
|
||||||
|
"message": "清空",
|
||||||
|
"description": "Translation key: textInputArea_clear"
|
||||||
|
},
|
||||||
|
"textInputArea_copyContent": {
|
||||||
|
"message": "复制内容",
|
||||||
|
"description": "Translation key: textInputArea_copyContent"
|
||||||
|
},
|
||||||
|
"textInputArea_cleared": {
|
||||||
|
"message": "已清空",
|
||||||
|
"description": "Translation key: textInputArea_cleared"
|
||||||
|
},
|
||||||
|
"textInputArea_placeholder": {
|
||||||
|
"message": "请输入文本",
|
||||||
|
"description": "Translation key: textInputArea_placeholder"
|
||||||
|
},
|
||||||
|
"dashboard_title": {
|
||||||
|
"message": "仪表盘",
|
||||||
|
"description": "Translation key: dashboard_title"
|
||||||
|
},
|
||||||
|
"dashboard_searchPlaceholder": {
|
||||||
|
"message": "搜索工具...",
|
||||||
|
"description": "Translation key: dashboard_searchPlaceholder"
|
||||||
|
},
|
||||||
|
"dashboard_recentlyUsed": {
|
||||||
|
"message": "最近使用",
|
||||||
|
"description": "Translation key: dashboard_recentlyUsed"
|
||||||
|
},
|
||||||
|
"dashboard_allTools": {
|
||||||
|
"message": "全部工具",
|
||||||
|
"description": "Translation key: dashboard_allTools"
|
||||||
|
},
|
||||||
|
"timestamp_title": {
|
||||||
|
"message": "时间戳",
|
||||||
|
"description": "Translation key: timestamp_title"
|
||||||
|
},
|
||||||
|
"timestamp_description": {
|
||||||
|
"message": "Unix 毫秒数转换与格式化",
|
||||||
|
"description": "Translation key: timestamp_description"
|
||||||
|
},
|
||||||
|
"storageCleaner_title": {
|
||||||
|
"message": "存储清理",
|
||||||
|
"description": "Translation key: storageCleaner_title"
|
||||||
|
},
|
||||||
|
"storageCleaner_description": {
|
||||||
|
"message": "清理缓存、Cookies 及本地存储",
|
||||||
|
"description": "Translation key: storageCleaner_description"
|
||||||
|
},
|
||||||
|
"qrCode_title": {
|
||||||
|
"message": "二维码工具",
|
||||||
|
"description": "Translation key: qrCode_title"
|
||||||
|
},
|
||||||
|
"qrCode_description": {
|
||||||
|
"message": "生成当前选中的 URL 的二维码",
|
||||||
|
"description": "Translation key: qrCode_description"
|
||||||
|
},
|
||||||
|
"textStatistics_title": {
|
||||||
|
"message": "文本统计",
|
||||||
|
"description": "Translation key: textStatistics_title"
|
||||||
|
},
|
||||||
|
"textStatistics_description": {
|
||||||
|
"message": "实时分析文本字符、单词及字节",
|
||||||
|
"description": "Translation key: textStatistics_description"
|
||||||
|
},
|
||||||
|
"jwt_title": {
|
||||||
|
"message": "JWT 解析",
|
||||||
|
"description": "Translation key: jwt_title"
|
||||||
|
},
|
||||||
|
"jwt_description": {
|
||||||
|
"message": "JSON Web Token 解码与查看",
|
||||||
|
"description": "Translation key: jwt_description"
|
||||||
|
},
|
||||||
|
"jsonDiff_title": {
|
||||||
|
"message": "JSON 工具",
|
||||||
|
"description": "Translation key: jsonDiff_title"
|
||||||
|
},
|
||||||
|
"jsonDiff_description": {
|
||||||
|
"message": "差异比较、格式化、YAML/TOML 转换及压缩",
|
||||||
|
"description": "Translation key: jsonDiff_description"
|
||||||
|
},
|
||||||
|
"base64Converter_title": {
|
||||||
|
"message": "Base64 转换器",
|
||||||
|
"description": "Translation key: base64Converter_title"
|
||||||
|
},
|
||||||
|
"base64Converter_description": {
|
||||||
|
"message": "文本、文件与图像的 Base64 编码转换",
|
||||||
|
"description": "Translation key: base64Converter_description"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_title": {
|
||||||
|
"message": "右键恢复",
|
||||||
|
"description": "Translation key: rightClickRestorer_title"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_description": {
|
||||||
|
"message": "检测并恢复被网站禁用的浏览器右键菜单",
|
||||||
|
"description": "Translation key: rightClickRestorer_description"
|
||||||
|
},
|
||||||
|
"base64Converter_pageTitle": {
|
||||||
|
"message": "Base64 转换器",
|
||||||
|
"description": "Translation key: base64Converter_pageTitle"
|
||||||
|
},
|
||||||
|
"base64Converter_textMode": {
|
||||||
|
"message": "文本",
|
||||||
|
"description": "Translation key: base64Converter_textMode"
|
||||||
|
},
|
||||||
|
"base64Converter_fileMode": {
|
||||||
|
"message": "文件",
|
||||||
|
"description": "Translation key: base64Converter_fileMode"
|
||||||
|
},
|
||||||
|
"base64Converter_imageMode": {
|
||||||
|
"message": "图像",
|
||||||
|
"description": "Translation key: base64Converter_imageMode"
|
||||||
|
},
|
||||||
|
"base64Converter_encode": {
|
||||||
|
"message": "编码",
|
||||||
|
"description": "Translation key: base64Converter_encode"
|
||||||
|
},
|
||||||
|
"base64Converter_decode": {
|
||||||
|
"message": "解码",
|
||||||
|
"description": "Translation key: base64Converter_decode"
|
||||||
|
},
|
||||||
|
"base64Converter_clear": {
|
||||||
|
"message": "清空",
|
||||||
|
"description": "Translation key: base64Converter_clear"
|
||||||
|
},
|
||||||
|
"base64Converter_textInputPlaceholder": {
|
||||||
|
"message": "输入需要编码为 Base64 的文本...",
|
||||||
|
"description": "Translation key: base64Converter_textInputPlaceholder"
|
||||||
|
},
|
||||||
|
"base64Converter_base64InputPlaceholder": {
|
||||||
|
"message": "输入需要解码的 Base64 字符串...",
|
||||||
|
"description": "Translation key: base64Converter_base64InputPlaceholder"
|
||||||
|
},
|
||||||
|
"base64Converter_base64Output": {
|
||||||
|
"message": "Base64 编码结果",
|
||||||
|
"description": "Translation key: base64Converter_base64Output"
|
||||||
|
},
|
||||||
|
"base64Converter_textOutput": {
|
||||||
|
"message": "解码文本结果",
|
||||||
|
"description": "Translation key: base64Converter_textOutput"
|
||||||
|
},
|
||||||
|
"base64Converter_copyRaw": {
|
||||||
|
"message": "复制纯 Base64",
|
||||||
|
"description": "Translation key: base64Converter_copyRaw"
|
||||||
|
},
|
||||||
|
"base64Converter_copyDataUri": {
|
||||||
|
"message": "复制 Data URI",
|
||||||
|
"description": "Translation key: base64Converter_copyDataUri"
|
||||||
|
},
|
||||||
|
"base64Converter_clickOrDropToFile": {
|
||||||
|
"message": "点击或拖拽文件到此处",
|
||||||
|
"description": "Translation key: base64Converter_clickOrDropToFile"
|
||||||
|
},
|
||||||
|
"base64Converter_clickOrDropToImage": {
|
||||||
|
"message": "点击或拖拽图像到此处",
|
||||||
|
"description": "Translation key: base64Converter_clickOrDropToImage"
|
||||||
|
},
|
||||||
|
"base64Converter_clickOrDropToReplace": {
|
||||||
|
"message": "点击或拖拽以替换文件",
|
||||||
|
"description": "Translation key: base64Converter_clickOrDropToReplace"
|
||||||
|
},
|
||||||
|
"base64Converter_maxFileSize": {
|
||||||
|
"message": "最大文件大小:{{max}}",
|
||||||
|
"description": "Translation key: base64Converter_maxFileSize"
|
||||||
|
},
|
||||||
|
"base64Converter_supportedFormats": {
|
||||||
|
"message": "支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式",
|
||||||
|
"description": "Translation key: base64Converter_supportedFormats"
|
||||||
|
},
|
||||||
|
"base64Converter_fileSizeExceeded": {
|
||||||
|
"message": "文件大小超出限制(最大 {{max}})",
|
||||||
|
"description": "Translation key: base64Converter_fileSizeExceeded"
|
||||||
|
},
|
||||||
|
"base64Converter_unsupportedImageType": {
|
||||||
|
"message": "不支持的图像格式",
|
||||||
|
"description": "Translation key: base64Converter_unsupportedImageType"
|
||||||
|
},
|
||||||
|
"base64Converter_conversionFailed": {
|
||||||
|
"message": "转换失败",
|
||||||
|
"description": "Translation key: base64Converter_conversionFailed"
|
||||||
|
},
|
||||||
|
"base64Converter_originalSize": {
|
||||||
|
"message": "原始大小",
|
||||||
|
"description": "Translation key: base64Converter_originalSize"
|
||||||
|
},
|
||||||
|
"base64Converter_encodedSize": {
|
||||||
|
"message": "编码大小",
|
||||||
|
"description": "Translation key: base64Converter_encodedSize"
|
||||||
|
},
|
||||||
|
"base64Converter_invalidBase64": {
|
||||||
|
"message": "Base64 字符串无效",
|
||||||
|
"description": "Translation key: base64Converter_invalidBase64"
|
||||||
|
},
|
||||||
|
"base64Converter_binaryDataDetected": {
|
||||||
|
"message": "输入似乎是二进制数据(如图片)。请切换到「图像」选项卡。",
|
||||||
|
"description": "Translation key: base64Converter_binaryDataDetected"
|
||||||
|
},
|
||||||
|
"base64Converter_imageDataUriHint": {
|
||||||
|
"message": "检测到图片的 data URI,请使用「图像」选项卡进行解码。",
|
||||||
|
"description": "Translation key: base64Converter_imageDataUriHint"
|
||||||
|
},
|
||||||
|
"base64Converter_switchToImageMode": {
|
||||||
|
"message": "切换到图像模式",
|
||||||
|
"description": "Translation key: base64Converter_switchToImageMode"
|
||||||
|
},
|
||||||
|
"base64Converter_download": {
|
||||||
|
"message": "下载",
|
||||||
|
"description": "Translation key: base64Converter_download"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedFileName": {
|
||||||
|
"message": "解码后文件名",
|
||||||
|
"description": "Translation key: base64Converter_decodedFileName"
|
||||||
|
},
|
||||||
|
"base64Converter_decodeBase64Placeholder": {
|
||||||
|
"message": "输入需要解码的 Base64 或 data URI...",
|
||||||
|
"description": "Translation key: base64Converter_decodeBase64Placeholder"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedFileOutput": {
|
||||||
|
"message": "解码文件",
|
||||||
|
"description": "Translation key: base64Converter_decodedFileOutput"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedImageOutput": {
|
||||||
|
"message": "解码图像",
|
||||||
|
"description": "Translation key: base64Converter_decodedImageOutput"
|
||||||
|
},
|
||||||
|
"base64Converter_inferredMimeType": {
|
||||||
|
"message": "推断的 MIME 类型",
|
||||||
|
"description": "Translation key: base64Converter_inferredMimeType"
|
||||||
|
},
|
||||||
|
"base64Converter_decodedSize": {
|
||||||
|
"message": "解码大小",
|
||||||
|
"description": "Translation key: base64Converter_decodedSize"
|
||||||
|
},
|
||||||
|
"jsonDiff_pageTitle": {
|
||||||
|
"message": "JSON 差异比较",
|
||||||
|
"description": "Translation key: jsonDiff_pageTitle"
|
||||||
|
},
|
||||||
|
"jsonDiff_leftPlaceholder": {
|
||||||
|
"message": "输入原始 JSON...",
|
||||||
|
"description": "Translation key: jsonDiff_leftPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonDiff_rightPlaceholder": {
|
||||||
|
"message": "输入目标 JSON...",
|
||||||
|
"description": "Translation key: jsonDiff_rightPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonDiff_leftLabel": {
|
||||||
|
"message": "原始 JSON",
|
||||||
|
"description": "Translation key: jsonDiff_leftLabel"
|
||||||
|
},
|
||||||
|
"jsonDiff_rightLabel": {
|
||||||
|
"message": "目标 JSON",
|
||||||
|
"description": "Translation key: jsonDiff_rightLabel"
|
||||||
|
},
|
||||||
|
"jsonDiff_sideBySideMode": {
|
||||||
|
"message": "并排",
|
||||||
|
"description": "Translation key: jsonDiff_sideBySideMode"
|
||||||
|
},
|
||||||
|
"jsonDiff_unifiedMode": {
|
||||||
|
"message": "统一",
|
||||||
|
"description": "Translation key: jsonDiff_unifiedMode"
|
||||||
|
},
|
||||||
|
"jsonDiff_previousDiff": {
|
||||||
|
"message": "上一个",
|
||||||
|
"description": "Translation key: jsonDiff_previousDiff"
|
||||||
|
},
|
||||||
|
"jsonDiff_nextDiff": {
|
||||||
|
"message": "下一个",
|
||||||
|
"description": "Translation key: jsonDiff_nextDiff"
|
||||||
|
},
|
||||||
|
"jsonDiff_noDiffs": {
|
||||||
|
"message": "无差异",
|
||||||
|
"description": "Translation key: jsonDiff_noDiffs"
|
||||||
|
},
|
||||||
|
"jsonDiff_invalidJson": {
|
||||||
|
"message": "无效的 JSON 格式",
|
||||||
|
"description": "Translation key: jsonDiff_invalidJson"
|
||||||
|
},
|
||||||
|
"jsonDiff_emptyHint": {
|
||||||
|
"message": "输入两侧 JSON 后点击比较",
|
||||||
|
"description": "Translation key: jsonDiff_emptyHint"
|
||||||
|
},
|
||||||
|
"jsonDiff_fixErrorHint": {
|
||||||
|
"message": "请修正上方 JSON 的语法错误以开启实时流式比对",
|
||||||
|
"description": "Translation key: jsonDiff_fixErrorHint"
|
||||||
|
},
|
||||||
|
"jsonDiff_added": {
|
||||||
|
"message": "新增",
|
||||||
|
"description": "Translation key: jsonDiff_added"
|
||||||
|
},
|
||||||
|
"jsonDiff_removed": {
|
||||||
|
"message": "删除",
|
||||||
|
"description": "Translation key: jsonDiff_removed"
|
||||||
|
},
|
||||||
|
"jsonDiff_modified": {
|
||||||
|
"message": "修改",
|
||||||
|
"description": "Translation key: jsonDiff_modified"
|
||||||
|
},
|
||||||
|
"jsonFormat_inputPlaceholder": {
|
||||||
|
"message": "输入需要格式化的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_inputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_sortKeys": {
|
||||||
|
"message": "键名排序",
|
||||||
|
"description": "Translation key: jsonFormat_sortKeys"
|
||||||
|
},
|
||||||
|
"jsonFormat_indentSize": {
|
||||||
|
"message": "缩进",
|
||||||
|
"description": "Translation key: jsonFormat_indentSize"
|
||||||
|
},
|
||||||
|
"jsonFormat_outputLabel": {
|
||||||
|
"message": "格式化结果",
|
||||||
|
"description": "Translation key: jsonFormat_outputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_invalidJson": {
|
||||||
|
"message": "无效的 JSON 格式",
|
||||||
|
"description": "Translation key: jsonFormat_invalidJson"
|
||||||
|
},
|
||||||
|
"jsonFormat_emptyHint": {
|
||||||
|
"message": "输入 JSON 后点击格式化",
|
||||||
|
"description": "Translation key: jsonFormat_emptyHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_fixErrorHint": {
|
||||||
|
"message": "请修正上方 JSON 的语法错误以开启实时流式格式化",
|
||||||
|
"description": "Translation key: jsonFormat_fixErrorHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_originalSize": {
|
||||||
|
"message": "原始大小",
|
||||||
|
"description": "Translation key: jsonFormat_originalSize"
|
||||||
|
},
|
||||||
|
"jsonFormat_formattedSize": {
|
||||||
|
"message": "格式化后大小",
|
||||||
|
"description": "Translation key: jsonFormat_formattedSize"
|
||||||
|
},
|
||||||
|
"jsonFormat_diffMode": {
|
||||||
|
"message": "差异比较",
|
||||||
|
"description": "Translation key: jsonFormat_diffMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_formatMode": {
|
||||||
|
"message": "格式化",
|
||||||
|
"description": "Translation key: jsonFormat_formatMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlMode": {
|
||||||
|
"message": "YAML",
|
||||||
|
"description": "Translation key: jsonFormat_yamlMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlMode": {
|
||||||
|
"message": "TOML",
|
||||||
|
"description": "Translation key: jsonFormat_tomlMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyMode": {
|
||||||
|
"message": "压缩",
|
||||||
|
"description": "Translation key: jsonFormat_minifyMode"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlModeInputPlaceholder": {
|
||||||
|
"message": "输入需要转换的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_yamlModeInputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlModeOutputLabel": {
|
||||||
|
"message": "YAML 结果",
|
||||||
|
"description": "Translation key: jsonFormat_yamlModeOutputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_yamlModeEmptyHint": {
|
||||||
|
"message": "输入 JSON 后点击转换",
|
||||||
|
"description": "Translation key: jsonFormat_yamlModeEmptyHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlModeInputPlaceholder": {
|
||||||
|
"message": "输入需要转换的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_tomlModeInputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlModeOutputLabel": {
|
||||||
|
"message": "TOML 结果",
|
||||||
|
"description": "Translation key: jsonFormat_tomlModeOutputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_tomlModeEmptyHint": {
|
||||||
|
"message": "输入 JSON 后点击转换",
|
||||||
|
"description": "Translation key: jsonFormat_tomlModeEmptyHint"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyModeInputPlaceholder": {
|
||||||
|
"message": "输入需要压缩的 JSON...",
|
||||||
|
"description": "Translation key: jsonFormat_minifyModeInputPlaceholder"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyModeOutputLabel": {
|
||||||
|
"message": "压缩结果",
|
||||||
|
"description": "Translation key: jsonFormat_minifyModeOutputLabel"
|
||||||
|
},
|
||||||
|
"jsonFormat_minifyModeEmptyHint": {
|
||||||
|
"message": "输入 JSON 后点击压缩",
|
||||||
|
"description": "Translation key: jsonFormat_minifyModeEmptyHint"
|
||||||
|
},
|
||||||
|
"jwt_pageTitle": {
|
||||||
|
"message": "JWT 解析",
|
||||||
|
"description": "Translation key: jwt_pageTitle"
|
||||||
|
},
|
||||||
|
"jwt_placeholder": {
|
||||||
|
"message": "在此粘贴 JWT 令牌 (Encoded JWT)...",
|
||||||
|
"description": "Translation key: jwt_placeholder"
|
||||||
|
},
|
||||||
|
"jwt_headerTitle": {
|
||||||
|
"message": "HEADER: 算法 & 令牌类型",
|
||||||
|
"description": "Translation key: jwt_headerTitle"
|
||||||
|
},
|
||||||
|
"jwt_payloadTitle": {
|
||||||
|
"message": "PAYLOAD: 数据",
|
||||||
|
"description": "Translation key: jwt_payloadTitle"
|
||||||
|
},
|
||||||
|
"jwt_signatureTitle": {
|
||||||
|
"message": "签名",
|
||||||
|
"description": "Translation key: jwt_signatureTitle"
|
||||||
|
},
|
||||||
|
"jwt_noSignature": {
|
||||||
|
"message": "无签名",
|
||||||
|
"description": "Translation key: jwt_noSignature"
|
||||||
|
},
|
||||||
|
"jwt_invalidFormat": {
|
||||||
|
"message": "无法解析",
|
||||||
|
"description": "Translation key: jwt_invalidFormat"
|
||||||
|
},
|
||||||
|
"jwt_errors_invalidBase64String": {
|
||||||
|
"message": "无效的 Base64URL 字符串",
|
||||||
|
"description": "Translation key: jwt_errors_invalidBase64String"
|
||||||
|
},
|
||||||
|
"jwt_errors_failedToDecode": {
|
||||||
|
"message": "Base64URL 解码失败:",
|
||||||
|
"description": "Translation key: jwt_errors_failedToDecode"
|
||||||
|
},
|
||||||
|
"jwt_errors_invalidFormat": {
|
||||||
|
"message": "JWT 格式错误:必须包含三个由 . 分隔的部分",
|
||||||
|
"description": "Translation key: jwt_errors_invalidFormat"
|
||||||
|
},
|
||||||
|
"jwt_errors_parseHeaderFailed": {
|
||||||
|
"message": "解析 Header 失败:",
|
||||||
|
"description": "Translation key: jwt_errors_parseHeaderFailed"
|
||||||
|
},
|
||||||
|
"jwt_errors_parsePayloadFailed": {
|
||||||
|
"message": "解析 Payload 失败:",
|
||||||
|
"description": "Translation key: jwt_errors_parsePayloadFailed"
|
||||||
|
},
|
||||||
|
"qrCode_pageTitle": {
|
||||||
|
"message": "二维码工具",
|
||||||
|
"description": "Translation key: qrCode_pageTitle"
|
||||||
|
},
|
||||||
|
"qrCode_urlToQr": {
|
||||||
|
"message": "文本转二维码",
|
||||||
|
"description": "Translation key: qrCode_urlToQr"
|
||||||
|
},
|
||||||
|
"qrCode_qrToUrl": {
|
||||||
|
"message": "二维码转文本",
|
||||||
|
"description": "Translation key: qrCode_qrToUrl"
|
||||||
|
},
|
||||||
|
"qrCode_urlInputLabel": {
|
||||||
|
"message": "输入 URL 或文本",
|
||||||
|
"description": "Translation key: qrCode_urlInputLabel"
|
||||||
|
},
|
||||||
|
"qrCode_urlInputPlaceholder": {
|
||||||
|
"message": "请输入 URL 或文本内容,将自动生成二维码",
|
||||||
|
"description": "Translation key: qrCode_urlInputPlaceholder"
|
||||||
|
},
|
||||||
|
"qrCode_generating": {
|
||||||
|
"message": "生成中...",
|
||||||
|
"description": "Translation key: qrCode_generating"
|
||||||
|
},
|
||||||
|
"qrCode_qrCodeWillShow": {
|
||||||
|
"message": "二维码将显示在这里",
|
||||||
|
"description": "Translation key: qrCode_qrCodeWillShow"
|
||||||
|
},
|
||||||
|
"qrCode_downloadButton": {
|
||||||
|
"message": "下载二维码",
|
||||||
|
"description": "Translation key: qrCode_downloadButton"
|
||||||
|
},
|
||||||
|
"qrCode_copyQrButton": {
|
||||||
|
"message": "复制二维码",
|
||||||
|
"description": "Translation key: qrCode_copyQrButton"
|
||||||
|
},
|
||||||
|
"qrCode_qrCodeDownloadSuccess": {
|
||||||
|
"message": "二维码下载成功",
|
||||||
|
"description": "Translation key: qrCode_qrCodeDownloadSuccess"
|
||||||
|
},
|
||||||
|
"qrCode_qrCodeCopySuccess": {
|
||||||
|
"message": "二维码已复制到剪贴板",
|
||||||
|
"description": "Translation key: qrCode_qrCodeCopySuccess"
|
||||||
|
},
|
||||||
|
"qrCode_parseSuccess": {
|
||||||
|
"message": "二维码解析成功",
|
||||||
|
"description": "Translation key: qrCode_parseSuccess"
|
||||||
|
},
|
||||||
|
"qrCode_noQrDetected": {
|
||||||
|
"message": "未检测到二维码,请确保图片清晰且包含二维码",
|
||||||
|
"description": "Translation key: qrCode_noQrDetected"
|
||||||
|
},
|
||||||
|
"qrCode_parseError": {
|
||||||
|
"message": "解析二维码失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_parseError"
|
||||||
|
},
|
||||||
|
"qrCode_copyError": {
|
||||||
|
"message": "复制失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_copyError"
|
||||||
|
},
|
||||||
|
"qrCode_imagePasted": {
|
||||||
|
"message": "图片粘贴成功,正在解析...",
|
||||||
|
"description": "Translation key: qrCode_imagePasted"
|
||||||
|
},
|
||||||
|
"qrCode_imagePasteError": {
|
||||||
|
"message": "粘贴图片失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_imagePasteError"
|
||||||
|
},
|
||||||
|
"qrCode_imageCleared": {
|
||||||
|
"message": "图片已清除",
|
||||||
|
"description": "Translation key: qrCode_imageCleared"
|
||||||
|
},
|
||||||
|
"qrCode_clickToUpload": {
|
||||||
|
"message": "点击、拖拽或粘贴上传二维码图片",
|
||||||
|
"description": "Translation key: qrCode_clickToUpload"
|
||||||
|
},
|
||||||
|
"qrCode_supportFormats": {
|
||||||
|
"message": "支持 PNG、JPG、WEBP、Base64 格式",
|
||||||
|
"description": "Translation key: qrCode_supportFormats"
|
||||||
|
},
|
||||||
|
"qrCode_resultLabel": {
|
||||||
|
"message": "解析结果",
|
||||||
|
"description": "Translation key: qrCode_resultLabel"
|
||||||
|
},
|
||||||
|
"qrCode_clickToChange": {
|
||||||
|
"message": "点击更换图片",
|
||||||
|
"description": "Translation key: qrCode_clickToChange"
|
||||||
|
},
|
||||||
|
"qrCode_generateButton": {
|
||||||
|
"message": "生成二维码",
|
||||||
|
"description": "Translation key: qrCode_generateButton"
|
||||||
|
},
|
||||||
|
"qrCode_editButton": {
|
||||||
|
"message": "编辑",
|
||||||
|
"description": "Translation key: qrCode_editButton"
|
||||||
|
},
|
||||||
|
"qrCode_textPreviewLabel": {
|
||||||
|
"message": "原始文本",
|
||||||
|
"description": "Translation key: qrCode_textPreviewLabel"
|
||||||
|
},
|
||||||
|
"qrCode_generateFirstHint": {
|
||||||
|
"message": "输入文本后点击「生成二维码」按钮",
|
||||||
|
"description": "Translation key: qrCode_generateFirstHint"
|
||||||
|
},
|
||||||
|
"qrCode_inputRequired": {
|
||||||
|
"message": "请输入内容",
|
||||||
|
"description": "Translation key: qrCode_inputRequired"
|
||||||
|
},
|
||||||
|
"qrCode_generateError": {
|
||||||
|
"message": "生成二维码失败,请重试",
|
||||||
|
"description": "Translation key: qrCode_generateError"
|
||||||
|
},
|
||||||
|
"qrCode_uploadedImage": {
|
||||||
|
"message": "已上传图片",
|
||||||
|
"description": "Translation key: qrCode_uploadedImage"
|
||||||
|
},
|
||||||
|
"qrCode_reuploadButton": {
|
||||||
|
"message": "重新上传",
|
||||||
|
"description": "Translation key: qrCode_reuploadButton"
|
||||||
|
},
|
||||||
|
"qrCode_parsing": {
|
||||||
|
"message": "解析中...",
|
||||||
|
"description": "Translation key: qrCode_parsing"
|
||||||
|
},
|
||||||
|
"qrCode_resultPlaceholder": {
|
||||||
|
"message": "解析结果将显示在此处",
|
||||||
|
"description": "Translation key: qrCode_resultPlaceholder"
|
||||||
|
},
|
||||||
|
"qrCode_imageToQr": {
|
||||||
|
"message": "解析图片二维码",
|
||||||
|
"description": "Translation key: qrCode_imageToQr"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_loading": {
|
||||||
|
"message": "正在加载...",
|
||||||
|
"description": "Translation key: rightClickRestorer_loading"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_currentDomain": {
|
||||||
|
"message": "当前域名",
|
||||||
|
"description": "Translation key: rightClickRestorer_currentDomain"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_statusLocked": {
|
||||||
|
"message": "未解锁",
|
||||||
|
"description": "Translation key: rightClickRestorer_statusLocked"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_statusUnlocked": {
|
||||||
|
"message": "已解锁",
|
||||||
|
"description": "Translation key: rightClickRestorer_statusUnlocked"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unsupported": {
|
||||||
|
"message": "不支持",
|
||||||
|
"description": "Translation key: rightClickRestorer_unsupported"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unsupportedDesc": {
|
||||||
|
"message": "当前页面为浏览器内部页面或扩展页面,无法解锁右键功能。请切换到普通网页后重试。",
|
||||||
|
"description": "Translation key: rightClickRestorer_unsupportedDesc"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unlockDesc": {
|
||||||
|
"message": "点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。",
|
||||||
|
"description": "Translation key: rightClickRestorer_unlockDesc"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_unlockBtn": {
|
||||||
|
"message": "解锁当前网站右键",
|
||||||
|
"description": "Translation key: rightClickRestorer_unlockBtn"
|
||||||
|
},
|
||||||
|
"rightClickRestorer_alreadyUnlocked": {
|
||||||
|
"message": "右键已解锁",
|
||||||
|
"description": "Translation key: rightClickRestorer_alreadyUnlocked"
|
||||||
|
},
|
||||||
|
"storageCleaner_pageTitle": {
|
||||||
|
"message": "存储清理",
|
||||||
|
"description": "Translation key: storageCleaner_pageTitle"
|
||||||
|
},
|
||||||
|
"storageCleaner_loading": {
|
||||||
|
"message": "加载中...",
|
||||||
|
"description": "Translation key: storageCleaner_loading"
|
||||||
|
},
|
||||||
|
"storageCleaner_initializing": {
|
||||||
|
"message": "正在读取站点数据...",
|
||||||
|
"description": "Translation key: storageCleaner_initializing"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleaning": {
|
||||||
|
"message": "正在清理...",
|
||||||
|
"description": "Translation key: storageCleaner_cleaning"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleanNow": {
|
||||||
|
"message": "立即清理",
|
||||||
|
"description": "Translation key: storageCleaner_cleanNow"
|
||||||
|
},
|
||||||
|
"storageCleaner_autoRefresh": {
|
||||||
|
"message": "清理后自动刷新页面",
|
||||||
|
"description": "Translation key: storageCleaner_autoRefresh"
|
||||||
|
},
|
||||||
|
"storageCleaner_selectAll": {
|
||||||
|
"message": "全选所有项",
|
||||||
|
"description": "Translation key: storageCleaner_selectAll"
|
||||||
|
},
|
||||||
|
"storageCleaner_noData": {
|
||||||
|
"message": "无数据",
|
||||||
|
"description": "Translation key: storageCleaner_noData"
|
||||||
|
},
|
||||||
|
"storageCleaner_errorNoTab": {
|
||||||
|
"message": "无法获取当前标签页",
|
||||||
|
"description": "Translation key: storageCleaner_errorNoTab"
|
||||||
|
},
|
||||||
|
"storageCleaner_errorRestricted": {
|
||||||
|
"message": "存储清理功能不支持此页面",
|
||||||
|
"description": "Translation key: storageCleaner_errorRestricted"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleanSuccessReload": {
|
||||||
|
"message": "清理成功,即将刷新页面",
|
||||||
|
"description": "Translation key: storageCleaner_cleanSuccessReload"
|
||||||
|
},
|
||||||
|
"storageCleaner_errorStandardOnly": {
|
||||||
|
"message": "存储清理功能仅适用于标准网页",
|
||||||
|
"description": "Translation key: storageCleaner_errorStandardOnly"
|
||||||
|
},
|
||||||
|
"storageCleaner_confirmTitle": {
|
||||||
|
"message": "确认清理数据?",
|
||||||
|
"description": "Translation key: storageCleaner_confirmTitle"
|
||||||
|
},
|
||||||
|
"storageCleaner_confirmDesc": {
|
||||||
|
"message": "您将永久删除当前页面的以下选定存储项。",
|
||||||
|
"description": "Translation key: storageCleaner_confirmDesc"
|
||||||
|
},
|
||||||
|
"storageCleaner_irreversible": {
|
||||||
|
"message": "此操作不可撤销",
|
||||||
|
"description": "Translation key: storageCleaner_irreversible"
|
||||||
|
},
|
||||||
|
"storageCleaner_confirmAction": {
|
||||||
|
"message": "确认清理",
|
||||||
|
"description": "Translation key: storageCleaner_confirmAction"
|
||||||
|
},
|
||||||
|
"storageCleaner_cleanedSummary": {
|
||||||
|
"message": "清理了 {{items}}",
|
||||||
|
"description": "Translation key: storageCleaner_cleanedSummary"
|
||||||
|
},
|
||||||
|
"storageCleaner_noDataToClean": {
|
||||||
|
"message": "该页面没有可清理的存储数据",
|
||||||
|
"description": "Translation key: storageCleaner_noDataToClean"
|
||||||
|
},
|
||||||
|
"storageCleaner_partialFailure": {
|
||||||
|
"message": "部分清理失败",
|
||||||
|
"description": "Translation key: storageCleaner_partialFailure"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_localStorage": {
|
||||||
|
"message": "Local Storage",
|
||||||
|
"description": "Translation key: storageCleaner_options_localStorage"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_sessionStorage": {
|
||||||
|
"message": "Session Storage",
|
||||||
|
"description": "Translation key: storageCleaner_options_sessionStorage"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_indexedDB": {
|
||||||
|
"message": "站点存储",
|
||||||
|
"description": "Translation key: storageCleaner_options_indexedDB"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_cookies": {
|
||||||
|
"message": "Cookies",
|
||||||
|
"description": "Translation key: storageCleaner_options_cookies"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_cacheStorage": {
|
||||||
|
"message": "Cache Storage",
|
||||||
|
"description": "Translation key: storageCleaner_options_cacheStorage"
|
||||||
|
},
|
||||||
|
"storageCleaner_options_serviceWorkers": {
|
||||||
|
"message": "Service Workers",
|
||||||
|
"description": "Translation key: storageCleaner_options_serviceWorkers"
|
||||||
|
},
|
||||||
|
"textStatistics_pageTitle": {
|
||||||
|
"message": "文本统计",
|
||||||
|
"description": "Translation key: textStatistics_pageTitle"
|
||||||
|
},
|
||||||
|
"textStatistics_placeholder": {
|
||||||
|
"message": "在此输入或粘贴文本...",
|
||||||
|
"description": "Translation key: textStatistics_placeholder"
|
||||||
|
},
|
||||||
|
"textStatistics_characters": {
|
||||||
|
"message": "字符数",
|
||||||
|
"description": "Translation key: textStatistics_characters"
|
||||||
|
},
|
||||||
|
"textStatistics_words": {
|
||||||
|
"message": "单词数",
|
||||||
|
"description": "Translation key: textStatistics_words"
|
||||||
|
},
|
||||||
|
"textStatistics_lines": {
|
||||||
|
"message": "行数",
|
||||||
|
"description": "Translation key: textStatistics_lines"
|
||||||
|
},
|
||||||
|
"textStatistics_bytes": {
|
||||||
|
"message": "字节大小",
|
||||||
|
"description": "Translation key: textStatistics_bytes"
|
||||||
|
},
|
||||||
|
"timestamp_pageTitle": {
|
||||||
|
"message": "时间戳转换",
|
||||||
|
"description": "Translation key: timestamp_pageTitle"
|
||||||
|
},
|
||||||
|
"timestamp_tsToDate": {
|
||||||
|
"message": "时间戳 → 日期",
|
||||||
|
"description": "Translation key: timestamp_tsToDate"
|
||||||
|
},
|
||||||
|
"timestamp_dateToTs": {
|
||||||
|
"message": "日期 → 时间戳",
|
||||||
|
"description": "Translation key: timestamp_dateToTs"
|
||||||
|
},
|
||||||
|
"timestamp_placeholderTs": {
|
||||||
|
"message": "输入时间戳...",
|
||||||
|
"description": "Translation key: timestamp_placeholderTs"
|
||||||
|
},
|
||||||
|
"timestamp_placeholderDate": {
|
||||||
|
"message": "YYYY-MM-DD HH:mm:ss",
|
||||||
|
"description": "Translation key: timestamp_placeholderDate"
|
||||||
|
},
|
||||||
|
"timestamp_unitMs": {
|
||||||
|
"message": "毫秒 (ms)",
|
||||||
|
"description": "Translation key: timestamp_unitMs"
|
||||||
|
},
|
||||||
|
"timestamp_unitS": {
|
||||||
|
"message": "秒 (s)",
|
||||||
|
"description": "Translation key: timestamp_unitS"
|
||||||
|
},
|
||||||
|
"timestamp_currentTs": {
|
||||||
|
"message": "当前时间戳",
|
||||||
|
"description": "Translation key: timestamp_currentTs"
|
||||||
|
},
|
||||||
|
"timestamp_useNowTooltip": {
|
||||||
|
"message": "填充到下方",
|
||||||
|
"description": "Translation key: timestamp_useNowTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_copyTsTooltip": {
|
||||||
|
"message": "复制时间戳",
|
||||||
|
"description": "Translation key: timestamp_copyTsTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_usedSuccess": {
|
||||||
|
"message": "已使用当前时间戳",
|
||||||
|
"description": "Translation key: timestamp_usedSuccess"
|
||||||
|
},
|
||||||
|
"timestamp_resultLabel": {
|
||||||
|
"message": "转换结果",
|
||||||
|
"description": "Translation key: timestamp_resultLabel"
|
||||||
|
},
|
||||||
|
"timestamp_copyResultTooltip": {
|
||||||
|
"message": "复制结果",
|
||||||
|
"description": "Translation key: timestamp_copyResultTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_relativeTime": {
|
||||||
|
"message": "相对时间",
|
||||||
|
"description": "Translation key: timestamp_relativeTime"
|
||||||
|
},
|
||||||
|
"timestamp_iso8601": {
|
||||||
|
"message": "ISO 8601",
|
||||||
|
"description": "Translation key: timestamp_iso8601"
|
||||||
|
},
|
||||||
|
"timestamp_utcTime": {
|
||||||
|
"message": "UTC 时间",
|
||||||
|
"description": "Translation key: timestamp_utcTime"
|
||||||
|
},
|
||||||
|
"timestamp_copyTooltip": {
|
||||||
|
"message": "复制",
|
||||||
|
"description": "Translation key: timestamp_copyTooltip"
|
||||||
|
},
|
||||||
|
"timestamp_resultEmpty": {
|
||||||
|
"message": "请输入并点击转换",
|
||||||
|
"description": "Translation key: timestamp_resultEmpty"
|
||||||
|
},
|
||||||
|
"common_buttons_cancel": {
|
||||||
|
"message": "取消",
|
||||||
|
"description": "Translation key: buttons_cancel"
|
||||||
|
},
|
||||||
|
"common_buttons_copy": {
|
||||||
|
"message": "复制",
|
||||||
|
"description": "Translation key: buttons_copy"
|
||||||
|
},
|
||||||
|
"common_buttons_themeMode_light": {
|
||||||
|
"message": "切换到深色模式",
|
||||||
|
"description": "Translation key: buttons_themeMode_light"
|
||||||
|
},
|
||||||
|
"common_buttons_themeMode_dark": {
|
||||||
|
"message": "切换到系统模式",
|
||||||
|
"description": "Translation key: buttons_themeMode_dark"
|
||||||
|
},
|
||||||
|
"common_buttons_themeMode_system": {
|
||||||
|
"message": "切换到浅色模式",
|
||||||
|
"description": "Translation key: buttons_themeMode_system"
|
||||||
|
},
|
||||||
|
"common_buttons_search": {
|
||||||
|
"message": "搜索工具...",
|
||||||
|
"description": "Translation key: buttons_search"
|
||||||
|
},
|
||||||
|
"common_buttons_back": {
|
||||||
|
"message": "返回",
|
||||||
|
"description": "Translation key: buttons_back"
|
||||||
|
},
|
||||||
|
"common_buttons_clearSearch": {
|
||||||
|
"message": "清除搜索",
|
||||||
|
"description": "Translation key: buttons_clearSearch"
|
||||||
|
},
|
||||||
|
"common_buttons_recentSearch": {
|
||||||
|
"message": "最近搜索",
|
||||||
|
"description": "Translation key: buttons_recentSearch"
|
||||||
|
},
|
||||||
|
"common_buttons_noResults": {
|
||||||
|
"message": "未找到相关工具",
|
||||||
|
"description": "Translation key: buttons_noResults"
|
||||||
|
},
|
||||||
|
"common_buttons_openInTab": {
|
||||||
|
"message": "在标签页打开",
|
||||||
|
"description": "Translation key: buttons_openInTab"
|
||||||
|
},
|
||||||
|
|
||||||
|
"common_errorBoundary_title": {
|
||||||
|
"message": "糟糕,出了点问题",
|
||||||
|
"description": "Translation key: errorBoundary_title"
|
||||||
|
},
|
||||||
|
"common_errorBoundary_description": {
|
||||||
|
"message": "应用遇到了一些意外错误。您可以尝试刷新页面或重置应用。",
|
||||||
|
"description": "Translation key: errorBoundary_description"
|
||||||
|
},
|
||||||
|
"common_errorBoundary_refresh": {
|
||||||
|
"message": "刷新应用",
|
||||||
|
"description": "Translation key: errorBoundary_refresh"
|
||||||
|
},
|
||||||
|
"common_errorBoundary_retry": {
|
||||||
|
"message": "重新尝试",
|
||||||
|
"description": "Translation key: errorBoundary_retry"
|
||||||
|
},
|
||||||
|
"common_pageErrorBoundary_title": {
|
||||||
|
"message": "该功能运行异常",
|
||||||
|
"description": "Translation key: pageErrorBoundary_title"
|
||||||
|
},
|
||||||
|
"common_pageErrorBoundary_description": {
|
||||||
|
"message": "该页面在加载或渲染时遇到了内部脚本错误。您可以尝试重试,或者通过导航菜单切换到其他工具。",
|
||||||
|
"description": "Translation key: pageErrorBoundary_description"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 669 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 716 KiB After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
# src/
|
||||||
|
|
||||||
|
源码目录,存放全局样式定义。
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
| ----------- | ----------------- |
|
||||||
|
| `index.css` | 全局 CSS 入口文件 |
|
||||||
|
|
||||||
|
## index.css
|
||||||
|
|
||||||
|
全局样式入口,包含:
|
||||||
|
|
||||||
|
- **Tailwind 指令**:`@tailwind base/components/utilities`
|
||||||
|
- **shadcn/ui CSS 变量**:定义 `--background`、`--primary`、`--destructive`、`--card`、`--muted`、`--accent`、`--border`、`--ring` 等语义化颜色变量
|
||||||
|
- **主题色值**:`:root`(亮色)和 `.dark`(暗色)两套完整的颜色定义
|
||||||
|
- **圆角变量**:`--radius` 定义全局圆角大小
|
||||||
|
|
||||||
|
## 修改注意事项
|
||||||
|
|
||||||
|
- 修改 CSS 变量会影响所有使用 shadcn/ui 语义化 token 的组件
|
||||||
|
- 新增颜色变量需同时在 `:root` 和 `.dark` 中定义
|
||||||
|
- 避免在组件中硬编码颜色值,应使用 CSS 变量或 Tailwind 的语义化类名
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Check, Copy } from 'lucide-react';
|
||||||
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants, type ButtonProps } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
|
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
|
||||||
|
text: string;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
|
text,
|
||||||
|
tooltip,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'icon',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n('common');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
toast.error(t('messages.copyEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await copyTextToClipboard(text);
|
||||||
|
if (success) {
|
||||||
|
toast.success(t('messages.copySuccess'));
|
||||||
|
setCopied(true);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
|
} else {
|
||||||
|
toast.error(t('messages.copyError'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
title={tooltip ?? t('buttons.copy')}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant, size }),
|
||||||
|
copied &&
|
||||||
|
'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* DecodeResultPaper
|
||||||
|
*
|
||||||
|
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
|
||||||
|
* 提取了二者 decode 输出区完全一致的结构:
|
||||||
|
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
|
||||||
|
*
|
||||||
|
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
|
||||||
|
*/
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatFileSize } from '@/utils/base64Converter';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
|
interface DecodeResultPaperProps {
|
||||||
|
/** 标题文案,由调用方传入 i18n key 对应的值(如 decodedFileOutput / decodedImageOutput) */
|
||||||
|
title: string;
|
||||||
|
/** 解码后推断的 MIME 类型 */
|
||||||
|
mimeType: string;
|
||||||
|
/** 解码后 Blob 的大小(字节) */
|
||||||
|
blobSize: number;
|
||||||
|
/** 当前文件名 */
|
||||||
|
fileName: string;
|
||||||
|
/** 文件名变更回调 */
|
||||||
|
onFileNameChange: (name: string) => void;
|
||||||
|
/** 下载按钮点击回调 */
|
||||||
|
onDownload: () => void;
|
||||||
|
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecodeResultPaper({
|
||||||
|
title,
|
||||||
|
mimeType,
|
||||||
|
blobSize,
|
||||||
|
fileName,
|
||||||
|
onFileNameChange,
|
||||||
|
onDownload,
|
||||||
|
children,
|
||||||
|
}: DecodeResultPaperProps) {
|
||||||
|
const { t } = useI18n('base64Converter');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
|
||||||
|
{/* 标题 */}
|
||||||
|
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
|
||||||
|
|
||||||
|
{/* 可选预览内容(ImageMode 的图片) */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* 文件信息 */}
|
||||||
|
<div className="flex gap-4 mb-3">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('inferredMimeType')}: {mimeType}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('decodedSize')}: {formatFileSize(blobSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件名输入 */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
{t('decodedFileName')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={fileName}
|
||||||
|
onChange={(e) => onFileNameChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下载按钮 */}
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={onDownload}
|
||||||
|
disabled={!fileName.trim()}
|
||||||
|
className="w-full rounded-lg font-bold"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
{t('download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { getMessage } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
if (this.state.hasError && prevProps.children !== this.props.children) {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center mt-16 mx-auto max-w-md">
|
||||||
|
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 shadow-sm">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||||
|
<AlertCircle className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-extrabold text-destructive mb-2">
|
||||||
|
{getMessage('errorBoundary_title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
{getMessage('errorBoundary_description')}
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<div className="mb-6 p-4 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-[200px] overflow-auto border border-border/40">
|
||||||
|
<pre className="font-mono text-xs whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||||
|
{this.state.error.toString()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="rounded-lg font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
{getMessage('errorBoundary_refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ErrorBoundary };
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Image, X } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
/** 选中的文件 */
|
||||||
|
selectedFile: File | null;
|
||||||
|
/** 文件变更回调 */
|
||||||
|
onFileChange: (file: File) => void;
|
||||||
|
/** 清除文件回调 */
|
||||||
|
onClearFile: () => void;
|
||||||
|
/** 文件预览 URL */
|
||||||
|
previewUrl: string;
|
||||||
|
/** 预览 URL 变更回调 */
|
||||||
|
onPreviewUrlChange: (url: string) => void;
|
||||||
|
/** 是否正在拖拽 */
|
||||||
|
dragging: boolean;
|
||||||
|
/** 拖拽状态变更回调 */
|
||||||
|
onDraggingChange: (dragging: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUploader = ({
|
||||||
|
selectedFile,
|
||||||
|
onFileChange,
|
||||||
|
onClearFile,
|
||||||
|
previewUrl,
|
||||||
|
onPreviewUrlChange,
|
||||||
|
dragging,
|
||||||
|
onDraggingChange,
|
||||||
|
}: ImageUploaderProps) => {
|
||||||
|
const { t } = useI18n('qrCode');
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
onFileChange(file);
|
||||||
|
onPreviewUrlChange(URL.createObjectURL(file));
|
||||||
|
},
|
||||||
|
[onFileChange, onPreviewUrlChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClearFile = useCallback(() => {
|
||||||
|
if (previewUrl) {
|
||||||
|
URL.revokeObjectURL(previewUrl);
|
||||||
|
}
|
||||||
|
onClearFile();
|
||||||
|
toast.success(t('qrCode:imageCleared'));
|
||||||
|
}, [previewUrl, onClearFile, t]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleFileChange(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
onDraggingChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onDraggingChange(false);
|
||||||
|
const droppedFile = e.dataTransfer.files?.[0];
|
||||||
|
if (droppedFile) {
|
||||||
|
handleFileChange(droppedFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听粘贴事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = async (e: ClipboardEvent) => {
|
||||||
|
const items = e.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const file = items[i].getAsFile();
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
handleFileChange(file);
|
||||||
|
toast.success(t('qrCode:imagePasted'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理粘贴图片失败:', error);
|
||||||
|
toast.error(t('qrCode:imagePasteError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('paste', handlePaste);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('paste', handlePaste);
|
||||||
|
};
|
||||||
|
}, [handleFileChange, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex flex-col items-center justify-center h-[250px] border-2 border-dashed rounded-xl p-4 cursor-pointer transition-all duration-200 ${
|
||||||
|
dragging
|
||||||
|
? 'border-green-600 bg-green-50'
|
||||||
|
: selectedFile
|
||||||
|
? 'border-green-600 bg-green-50/50'
|
||||||
|
: 'border-input bg-muted hover:border-green-600 hover:bg-green-500/10/50'
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
id="qr-code-upload"
|
||||||
|
/>
|
||||||
|
<label htmlFor="qr-code-upload" className="cursor-pointer text-center w-full">
|
||||||
|
{selectedFile ? (
|
||||||
|
<div className="text-center w-full relative">
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
className="max-w-full max-h-40 rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="ClearIcon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClearFile();
|
||||||
|
}}
|
||||||
|
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t('qrCode:clickToChange')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
data-testid="ImageIcon"
|
||||||
|
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
|
||||||
|
/>
|
||||||
|
<span className="block text-sm text-muted-foreground mb-1">
|
||||||
|
{t('qrCode:clickToUpload')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{t('qrCode:supportFormats')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploader;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { getMessage } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
resetKey?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PageErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error in page:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props) {
|
||||||
|
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRetry = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<div className="p-6 text-center rounded-xl border border-destructive/20 bg-destructive/5 max-w-md w-full shadow-sm">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10 text-destructive mx-auto mb-4">
|
||||||
|
<AlertCircle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-base font-semibold text-foreground mb-1.5">
|
||||||
|
{getMessage('pageErrorBoundary_title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mb-5">
|
||||||
|
{getMessage('pageErrorBoundary_description')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{this.state.error && (
|
||||||
|
<div className="mb-5 p-3 rounded-lg bg-zinc-950 dark:bg-zinc-900 text-left max-h-40 overflow-y-auto border border-border/40">
|
||||||
|
<pre className="font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-zinc-200 selection:bg-zinc-700">
|
||||||
|
{this.state.error.stack || this.state.error.toString()}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={this.handleRetry}
|
||||||
|
className="font-medium shadow-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
{getMessage('errorBoundary_retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PageErrorBoundary };
|
||||||
|
export default PageErrorBoundary;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* PageSkeleton 组件 - 页面加载骨架屏
|
||||||
|
*
|
||||||
|
* 用于 Suspense fallback 和初始加载状态,提供平滑的视觉过渡
|
||||||
|
* 避免白屏闪烁,减少布局偏移
|
||||||
|
*/
|
||||||
|
interface PageSkeletonProps {
|
||||||
|
/** 骨架屏类型 */
|
||||||
|
variant?: 'dashboard' | 'tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘卡片骨架屏
|
||||||
|
*/
|
||||||
|
function DashboardCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border p-5 h-[100px]">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-muted animate-pulse" />
|
||||||
|
<div>
|
||||||
|
<div className="w-24 h-5 bg-muted rounded animate-pulse" />
|
||||||
|
<div className="w-32 h-3.5 bg-muted rounded animate-pulse mt-1.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-3 h-3 rounded-full bg-muted animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具页面骨架屏
|
||||||
|
*/
|
||||||
|
function ToolPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-5">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="w-44 h-7 bg-muted rounded animate-pulse mb-4" />
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<div className="w-full h-[120px] bg-muted rounded-xl animate-pulse mb-4" />
|
||||||
|
|
||||||
|
{/* 控制栏 */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果区域 */}
|
||||||
|
<div className="w-full h-[160px] bg-muted rounded-xl animate-pulse" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面加载骨架屏
|
||||||
|
*
|
||||||
|
* @param props - PageSkeletonProps
|
||||||
|
* @returns 骨架屏 JSX 元素
|
||||||
|
*/
|
||||||
|
export default function PageSkeleton({ variant = 'dashboard' }: PageSkeletonProps) {
|
||||||
|
if (variant === 'tool') {
|
||||||
|
return <ToolPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(300px,1fr))] auto-rows-fr gap-4 p-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<DashboardCardSkeleton key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PageSkeleton.displayName = 'PageSkeleton';
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Copy, Download } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
|
||||||
|
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
/** 二维码 Data URL */
|
||||||
|
qrCodeDataUrl: string;
|
||||||
|
/** 下载回调 */
|
||||||
|
onDownload: () => void;
|
||||||
|
/** 复制回调 */
|
||||||
|
onCopy: () => void;
|
||||||
|
/** 自定义占位文本,用于空状态提示 */
|
||||||
|
placeholderText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QrCodePreview = ({
|
||||||
|
qrCodeDataUrl,
|
||||||
|
onDownload,
|
||||||
|
onCopy,
|
||||||
|
placeholderText,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: QrCodePreviewProps) => {
|
||||||
|
const { t } = useI18n('qrCode');
|
||||||
|
|
||||||
|
// 空状态下的虚线骨架屏
|
||||||
|
if (!qrCodeDataUrl) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{placeholderText || t('qrCode:qrCodeWillShow')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col justify-center items-center border border-input rounded-xl p-4 bg-muted/40',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center w-full max-w-xs">
|
||||||
|
{/*
|
||||||
|
二维码容器适配:
|
||||||
|
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
|
||||||
|
通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
|
||||||
|
*/}
|
||||||
|
<div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
|
||||||
|
<img
|
||||||
|
src={qrCodeDataUrl}
|
||||||
|
alt="QR Code Preview"
|
||||||
|
className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full gap-2 mt-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
|
||||||
|
<Download className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<span className="truncate text-xs">{t('qrCode:downloadButton')}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
|
||||||
|
<Copy className="w-3.5 h-3.5" />
|
||||||
|
<span className="truncate text-xs">{t('qrCode:copyQrButton')}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QrCodePreview;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# components/
|
||||||
|
|
||||||
|
通用业务组件目录,存放跨页面复用的 UI 组件,与具体工具页面解耦。
|
||||||
|
|
||||||
|
## 组件列表
|
||||||
|
|
||||||
|
| 组件 | 用途 |
|
||||||
|
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `TopBar.tsx` | 顶部导航栏,集成搜索(含历史记录)、主题切换、语言切换、返回导航 |
|
||||||
|
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||||
|
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||||
|
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||||
|
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 |
|
||||||
|
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
|
||||||
|
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
|
||||||
|
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
|
||||||
|
| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 |
|
||||||
|
| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 |
|
||||||
|
| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 |
|
||||||
|
| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback |
|
||||||
|
|
||||||
|
## 使用约定
|
||||||
|
|
||||||
|
- 优先使用 `components/ui/` 下的 shadcn/ui 基础组件
|
||||||
|
- 组件使用 `cn()` 合并 Tailwind 类名,支持 `className` 透传
|
||||||
|
- 需要 memo 优化的组件使用 `React.memo` + `displayName`
|
||||||
|
- 需要 ref 转发的组件使用 `React.forwardRef` + `displayName`
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const entryPointType = getEntryPointType();
|
||||||
|
|
||||||
|
export default function RouterContainer() {
|
||||||
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
const { t } = useI18n('common');
|
||||||
|
|
||||||
|
const animationClass =
|
||||||
|
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
|
|
||||||
|
if (!isLoaded) {
|
||||||
|
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||||
|
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={currentPage}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||||
|
'scrollbar-gutter-stable motion-reduce:transition-none',
|
||||||
|
animationClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||||
|
>
|
||||||
|
<PageErrorBoundary resetKey={currentPage}>
|
||||||
|
{MatchedComponent ? (
|
||||||
|
<MatchedComponent />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||||
|
<AlertTriangle className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{t('router.notFound')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||||
|
{t('router.notFoundDescription', { entryPointType })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SwitchOption<T extends string | number = string> {
|
||||||
|
value: T;
|
||||||
|
label: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SwitchButtonGroupProps<T extends string | number = string> extends Omit<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
value: T;
|
||||||
|
options: SwitchOption<T>[];
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
buttonClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
size = 'medium',
|
||||||
|
className,
|
||||||
|
buttonClassName,
|
||||||
|
...props
|
||||||
|
}: SwitchButtonGroupProps<T>) {
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'text-xs h-8 px-2 py-1 rounded-md',
|
||||||
|
medium: 'text-sm h-9 px-3 py-1.5 rounded-md',
|
||||||
|
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-flex w-full items-center justify-center rounded-lg bg-muted text-muted-foreground p-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 inline-flex items-center justify-center font-medium whitespace-nowrap transition-all',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
sizeClasses[size],
|
||||||
|
isSelected
|
||||||
|
? 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'
|
||||||
|
: 'hover:bg-background/50 hover:text-foreground/80',
|
||||||
|
buttonClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
|
||||||
|
import { CopyButton } from '@/components/CopyButton';
|
||||||
|
|
||||||
|
export type ValidateRule = {
|
||||||
|
validator: (value: string) => boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolbarAction = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
position?: 'top' | 'bottom';
|
||||||
|
type?: 'primary' | 'default' | 'danger';
|
||||||
|
disabled?: boolean | ((value: string) => boolean);
|
||||||
|
onClick: (value: string, helpers: { clear: () => void; setError: (msg: string) => void }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TextInputAreaProps extends Omit<
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
'onChange'
|
||||||
|
> {
|
||||||
|
value?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
|
||||||
|
/** 值变化回调,返回最新的字符串内容 */
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
|
||||||
|
minRows?: number;
|
||||||
|
maxRows?: number;
|
||||||
|
showCount?: boolean;
|
||||||
|
showClear?: boolean;
|
||||||
|
allowCopy?: boolean;
|
||||||
|
rules?: ValidateRule[];
|
||||||
|
validateTrigger?: 'onBlur' | 'onChange' | 'onAction';
|
||||||
|
actions?: ToolbarAction[];
|
||||||
|
topExtra?: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
externalError?: string;
|
||||||
|
onClear?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
|
||||||
|
function ActionButton({
|
||||||
|
action,
|
||||||
|
value,
|
||||||
|
globalDisabled,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
action: ToolbarAction;
|
||||||
|
value: string;
|
||||||
|
globalDisabled: boolean;
|
||||||
|
onAction: (action: ToolbarAction) => void;
|
||||||
|
}) {
|
||||||
|
const isBtnDisabled =
|
||||||
|
typeof action.disabled === 'function' ? action.disabled(value) : (action.disabled ?? false);
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
danger: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
default:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAction(action)}
|
||||||
|
disabled={isBtnDisabled || globalDisabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors h-7 px-2.5',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
variantClasses[action.type || 'default'],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{action.icon && <span className="mr-1.5 h-3.5 w-3.5 flex items-center">{action.icon}</span>}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
value: controlledValue,
|
||||||
|
defaultValue = '',
|
||||||
|
onChange,
|
||||||
|
placeholder: placeholderProp,
|
||||||
|
disabled = false,
|
||||||
|
readOnly = false,
|
||||||
|
autoFocus = false,
|
||||||
|
minRows = 4,
|
||||||
|
maxRows = 12,
|
||||||
|
maxLength,
|
||||||
|
className,
|
||||||
|
showCount = false,
|
||||||
|
showClear = true,
|
||||||
|
allowCopy = false,
|
||||||
|
rules = [],
|
||||||
|
validateTrigger = 'onAction',
|
||||||
|
actions = [],
|
||||||
|
topExtra,
|
||||||
|
title,
|
||||||
|
externalError,
|
||||||
|
onClear,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const [internalValue, setInternalValue] = useState(defaultValue);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const { t } = useI18n('common');
|
||||||
|
const placeholder = placeholderProp ?? t('textInputArea.placeholder');
|
||||||
|
|
||||||
|
const isControlled = controlledValue !== undefined;
|
||||||
|
const value = isControlled ? controlledValue : internalValue;
|
||||||
|
const displayError = externalError ?? error;
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => internalRef.current as HTMLTextAreaElement);
|
||||||
|
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
const textArea = internalRef.current;
|
||||||
|
if (!textArea) return;
|
||||||
|
|
||||||
|
// 重置高度计算
|
||||||
|
textArea.style.height = 'auto';
|
||||||
|
|
||||||
|
const computedMin = minRows * 24;
|
||||||
|
const computedMax = maxRows * 24;
|
||||||
|
const nextHeight = Math.max(textArea.scrollHeight, computedMin);
|
||||||
|
|
||||||
|
textArea.style.height = `${Math.min(nextHeight, computedMax)}px`;
|
||||||
|
}, [minRows, maxRows]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
adjustHeight();
|
||||||
|
}, [value, adjustHeight]);
|
||||||
|
|
||||||
|
const validate = useCallback(
|
||||||
|
(val: string, trigger?: string): boolean => {
|
||||||
|
if (validateTrigger !== trigger && trigger) return true;
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (!rule.validator(val)) {
|
||||||
|
setError(rule.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[rules, validateTrigger],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newVal = e.target.value;
|
||||||
|
if (maxLength && newVal.length > maxLength) {
|
||||||
|
const msg = t('charCount', { count: maxLength });
|
||||||
|
setError(msg);
|
||||||
|
toast.warning(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isControlled) setInternalValue(newVal);
|
||||||
|
onChange?.(newVal);
|
||||||
|
|
||||||
|
if (error) setError('');
|
||||||
|
if (validateTrigger === 'onChange') validate(newVal, 'onChange');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (validateTrigger === 'onBlur') validate(value, 'onBlur');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
if (!isControlled) setInternalValue('');
|
||||||
|
onChange?.('');
|
||||||
|
setError('');
|
||||||
|
internalRef.current?.focus();
|
||||||
|
toast.success(t('textInputArea.cleared'));
|
||||||
|
onClear?.();
|
||||||
|
}, [isControlled, onChange, onClear, t]);
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
(action: ToolbarAction) => {
|
||||||
|
const isDisabled =
|
||||||
|
typeof action.disabled === 'function' ? action.disabled(value) : action.disabled;
|
||||||
|
if (isDisabled || disabled) return;
|
||||||
|
|
||||||
|
if (validateTrigger === 'onAction' && !validate(value, 'onAction')) return;
|
||||||
|
|
||||||
|
action.onClick(value, {
|
||||||
|
clear: handleClear,
|
||||||
|
setError,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[value, disabled, validate, validateTrigger, handleClear],
|
||||||
|
);
|
||||||
|
|
||||||
|
const topActions = actions.filter((a) => a.position !== 'bottom');
|
||||||
|
const bottomActions = actions.filter((a) => a.position === 'bottom');
|
||||||
|
const hasTopBar = title || showCount || topActions.length > 0 || topExtra;
|
||||||
|
const hasBottomBar = allowCopy || showClear || bottomActions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full flex flex-col gap-1.5', className)}>
|
||||||
|
{hasTopBar && (
|
||||||
|
<div className="flex items-center justify-between px-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{title && <span className="text-xs font-semibold text-muted-foreground">{title}</span>}
|
||||||
|
{topExtra}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{topActions.map((action) => (
|
||||||
|
<ActionButton
|
||||||
|
key={action.key}
|
||||||
|
action={action}
|
||||||
|
value={value}
|
||||||
|
globalDisabled={disabled}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showCount && (
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{value.length}
|
||||||
|
{maxLength ? ` / ${maxLength}` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-input bg-background shadow-sm transition-all focus-within:ring-1 focus-within:ring-ring focus-within:border-input overflow-hidden',
|
||||||
|
displayError &&
|
||||||
|
'border-destructive focus-within:ring-destructive focus-within:border-destructive',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={internalRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
readOnly={readOnly}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full bg-transparent px-4 py-3 font-mono text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/50 focus:outline-none resize-none border-0 block"
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasBottomBar && (
|
||||||
|
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
|
||||||
|
{/* 左侧自定义动作 */}
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{bottomActions.map((action) => (
|
||||||
|
<ActionButton
|
||||||
|
key={action.key}
|
||||||
|
action={action}
|
||||||
|
value={value}
|
||||||
|
globalDisabled={disabled}
|
||||||
|
onAction={handleAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧系统按钮组 */}
|
||||||
|
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||||
|
{allowCopy && value && (
|
||||||
|
<CopyButton
|
||||||
|
text={value}
|
||||||
|
tooltip={t('textInputArea.copyContent')}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showClear && value && !disabled && !readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label={t('textInputArea.clear')}
|
||||||
|
className="p-1 h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{displayError && (
|
||||||
|
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||||
|
{displayError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TextInputArea.displayName = 'TextInputArea';
|
||||||
|
|
||||||
|
export default TextInputArea;
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ArrowLeft, ExternalLink, Monitor, Moon, Search, Sun, X } from 'lucide-react';
|
||||||
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
|
import { FeatureConfig, FEATURES } from '@/config/features';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||||
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const SEARCH_HISTORY_LIMIT = 10;
|
||||||
|
const SEARCH_HISTORY_DISPLAY = 5;
|
||||||
|
|
||||||
|
export default function TopBar() {
|
||||||
|
const { currentPage, goBack, navigateTo } = useRouter();
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
|
const { t } = useI18n(['common', 'features']);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleOpenInTab = async () => {
|
||||||
|
await openExtensionPage('popup.html', { mode: 'tab' });
|
||||||
|
window.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cmd/Ctrl+K 快捷键聚焦搜索框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
setShowResults(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storageUtil
|
||||||
|
.get('app/searchHistory', [])
|
||||||
|
.then((history) => {
|
||||||
|
if (history) setSearchHistory(history);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('加载搜索历史失败:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchResults = useMemo(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (!query) return [];
|
||||||
|
return FEATURES.filter((f) => {
|
||||||
|
if (f.key === 'dashboard') return false;
|
||||||
|
return (
|
||||||
|
t(f.labelKey).toLowerCase().includes(query) ||
|
||||||
|
t(f.descriptionKey).toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [searchQuery, t]);
|
||||||
|
|
||||||
|
const displayedHistory = useMemo(() => {
|
||||||
|
if (searchQuery.trim()) return [];
|
||||||
|
return searchHistory
|
||||||
|
.slice(0, SEARCH_HISTORY_DISPLAY)
|
||||||
|
.map((key) => ({ key, feature: FEATURES.find((f) => f.key === key) }))
|
||||||
|
.filter((item) => item.feature && item.feature.key !== 'dashboard');
|
||||||
|
}, [searchHistory, searchQuery]);
|
||||||
|
|
||||||
|
const saveToHistory = async (featureKey: string) => {
|
||||||
|
if (!featureKey.trim()) return;
|
||||||
|
const nextHistory = [featureKey, ...searchHistory.filter((h) => h !== featureKey)].slice(
|
||||||
|
0,
|
||||||
|
SEARCH_HISTORY_LIMIT,
|
||||||
|
);
|
||||||
|
setSearchHistory(nextHistory);
|
||||||
|
await storageUtil.set('app/searchHistory', nextHistory).catch((err) => console.error(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFeature = (feature: FeatureConfig) => {
|
||||||
|
navigateTo(feature.key);
|
||||||
|
saveToHistory(feature.key);
|
||||||
|
setSearchQuery('');
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleThemeMode = () => {
|
||||||
|
const nextMap = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||||
|
setMode(nextMap[mode]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeIcon = mode === 'light' ? Sun : mode === 'dark' ? Moon : Monitor;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedIndex >= 0 && selectedIndex < totalItems) {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
handleSelectFeature(searchResults[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
const selected = displayedHistory[selectedIndex];
|
||||||
|
if (selected?.feature) {
|
||||||
|
handleSelectFeature(selected.feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (searchQuery.trim() && searchResults.length > 0) {
|
||||||
|
handleSelectFeature(searchResults[0]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setShowResults(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDashboard = currentPage === 'dashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-background px-4 relative z-50">
|
||||||
|
{/* 左侧:返回按钮区 */}
|
||||||
|
<div className="flex w-10 items-center justify-start">
|
||||||
|
{!isDashboard && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goBack}
|
||||||
|
aria-label={t('common_buttons_back')}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间:搜索容器 */}
|
||||||
|
<div ref={containerRef} className="flex-1 mx-4 max-w-md relative">
|
||||||
|
<div className="relative group">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60 group-focus-within:text-muted-foreground transition-colors pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={t('common_buttons_search')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setShowResults(true);
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowResults(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
aria-label={t('common_buttons_search')}
|
||||||
|
className="w-full h-9 pl-9 pr-16 text-sm rounded-lg border border-border/60 bg-muted/40 transition-all placeholder:text-muted-foreground/50 focus:bg-background focus:outline-none focus:ring-1 focus:ring-ring focus:border-input"
|
||||||
|
/>
|
||||||
|
{!searchQuery && (
|
||||||
|
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 pointer-events-none">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedIndex(-1);
|
||||||
|
}}
|
||||||
|
aria-label={t('common:buttons.clearSearch')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 动态联想结果卡片 */}
|
||||||
|
{showResults && (searchQuery.trim() || displayedHistory.length > 0) && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1.5 bg-popover text-popover-foreground rounded-lg shadow-lg border border-border max-h-80 overflow-y-auto z-50 animate-in fade-in slide-in-from-top-2 duration-150">
|
||||||
|
<ul role="listbox" className="p-1.5">
|
||||||
|
{searchQuery.trim() ? (
|
||||||
|
searchResults.length > 0 ? (
|
||||||
|
searchResults.map((feature, index) => (
|
||||||
|
<li
|
||||||
|
key={feature.key}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIndex === index}
|
||||||
|
onClick={() => handleSelectFeature(feature)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer text-sm transition-colors',
|
||||||
|
selectedIndex === index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-muted/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
'bg-muted/80 text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{feature.icon && <feature.icon className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{t(feature.labelKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{t(feature.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t('common:buttons.noResults')}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-1.5 text-[11px] font-semibold tracking-wider text-muted-foreground/60 uppercase">
|
||||||
|
{t('common:buttons.recentSearch')}
|
||||||
|
</div>
|
||||||
|
{displayedHistory.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.key}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selectedIndex === index}
|
||||||
|
onClick={() => item.feature && handleSelectFeature(item.feature)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm transition-colors',
|
||||||
|
selectedIndex === index
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'hover:bg-muted/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
'bg-muted/80 text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.feature?.icon && <item.feature.icon className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-foreground truncate">
|
||||||
|
{item.feature && t(item.feature.labelKey)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||||
|
{item.feature && t(item.feature.descriptionKey)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:操作区 */}
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<IconButton onClick={cycleThemeMode} title={t(`common:buttons.themeMode.${mode}`)}>
|
||||||
|
<ThemeIcon className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={handleOpenInTab} title={t('common:buttons.openInTab')}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 提炼出高度复用的原子按钮,大幅精简 Tailwind 冗余,符合 shadcn 的灵巧风格
|
||||||
|
function IconButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import DecodeResultPaper from '@/components/DecodeResultPaper';
|
||||||
|
|
||||||
|
describe('DecodeResultPaper 组件', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
title: 'decodedFileOutput',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
blobSize: 1024,
|
||||||
|
fileName: 'decoded.png',
|
||||||
|
onFileNameChange: vi.fn(),
|
||||||
|
onDownload: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('渲染测试', () => {
|
||||||
|
it('应渲染标题', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByText('decodedFileOutput')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染 MIME 类型信息', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByText(/image\/png/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应通过 formatFileSize 渲染文件大小', () => {
|
||||||
|
render(<DecodeResultPaper {...{ ...defaultProps, blobSize: 1536 }} />);
|
||||||
|
expect(screen.getByText(/1\.5 KB/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染文件名输入框', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
const input = screen.getByDisplayValue('decoded.png');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染下载按钮', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: '下载' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应渲染 children 内容', () => {
|
||||||
|
render(
|
||||||
|
<DecodeResultPaper {...defaultProps}>
|
||||||
|
<div data-testid="preview">预览内容</div>
|
||||||
|
</DecodeResultPaper>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('preview')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('预览内容')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('交互测试', () => {
|
||||||
|
it('修改文件名时应调用 onFileNameChange', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
const input = screen.getByDisplayValue('decoded.png');
|
||||||
|
fireEvent.change(input, { target: { value: 'new-name.png' } });
|
||||||
|
expect(defaultProps.onFileNameChange).toHaveBeenCalledWith('new-name.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('点击下载按钮时应调用 onDownload', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '下载' }));
|
||||||
|
expect(defaultProps.onDownload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('按钮状态', () => {
|
||||||
|
it('文件名为空时下载按钮应禁用', () => {
|
||||||
|
render(<DecodeResultPaper {...{ ...defaultProps, fileName: '' }} />);
|
||||||
|
expect(screen.getByRole('button', { name: '下载' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('文件名不为空时下载按钮应启用', () => {
|
||||||
|
render(<DecodeResultPaper {...defaultProps} />);
|
||||||
|
expect(screen.getByRole('button', { name: '下载' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||