Compare commits

...

16 Commits

Author SHA1 Message Date
Cursor Agent 938c1c79d6 fix(测试数据生成器): 修复 Worker 并发生成导致的数据污染
Co-authored-by: LingandRX <LingandRX@users.noreply.github.com>
2026-06-28 11:04:38 +00:00
雨霖铃 2967a6f79f fix: 修复 ruleStorage 写入失败处理与 CopyButton 成功态样式 2026-06-27 17:53:08 +08:00
雨霖铃 92bca1c728 refactor: 更新 Worker 消息类型以支持生成任务 ID 2026-06-27 17:47:58 +08:00
雨霖铃 ce0af7dff3 Merge branch 'main' into develop 2026-06-27 17:42:43 +08:00
雨霖铃 d38bafe237 refactor: 统一中文文案并简化组件结构,提升代码可读性
移除 chrome.i18n 后,将各功能页面、布局与工具模块的 UI 文案改为内联中文;
删除冗余注释与过度抽象(如 ToolCard、StatCard),精简 props 与状态管理;
同步优化 JsonTools、StorageCleaner、Timestamp、TestDataGenerator、Dashboard、
Base64Converter、RightClickRestorer、TextStatistics、TopBar、RouterProvider、
ThemeModeProvider 及生成器库与 utils 工具文件。
2026-06-27 17:26:19 +08:00
cursor[bot] f3a9838017 docs: 同步 i18n 移除与存储初始化防覆盖说明 (#73)
- 移除已废弃的 chrome.i18n / _locales 文档引用
- 补充 UI 文案规范(features.tsx + 组件内中文)
- 记录 RouterProvider / useStorageState 初始化防覆盖机制
- 更新 syncSnapshot、themeSnapshot、restrictedUrls 工具说明
2026-06-27 10:36:18 +08:00
Cursor Agent 0545910b16 fix: 修复路由初始化竞态、存储状态静默覆盖与存储清理误删 2026-06-26 17:17:17 +08:00
cursor[bot] 1948ba67a7 docs: 同步工程文档与当前架构 (#70)
* docs(工程文档): 同步当前架构说明
* docs(工程文档): 格式化文档表格
2026-06-26 17:16:10 +08:00
cursor[bot] 4b38133cf8 fix(测试数据生成器): 修复 Worker 取消失效与规则存储静默失败 (#69)
- 为生成任务引入 generationId,忽略过期 Worker 响应,避免取消后展示错误数据
- Worker 每 100 行让出事件循环,使 cancel 消息能被及时处理
- ruleStorage.setAll 返回写入结果,save/update/delete 在 QuotaExceeded 时返回失败
- 补充 useGenerator 与 ruleStorage 单元测试
2026-06-26 17:07:35 +08:00
cursor[bot] 5ae9cf065f docs: 同步工程文档与当前架构 (#70)
* docs(工程文档): 同步当前架构说明
* docs(工程文档): 格式化文档表格
2026-06-26 16:59:53 +08:00
rsgltzyd 1041b3ece1 docs: 更新AGENTS.md文档并清理废弃文件
1. 重构AGENTS.md内容,补充功能列表、项目结构说明
2. 删除CLAUDE.md、src/entrypoints/README.md、src/pages/README.md、.github/copilot-instructions.md等废弃文档
2026-06-26 13:54:14 +08:00
LingandRX a163297de4 develop (#68)
* docs(AGENTS.md): 添加 Git Commit 必须使用中文描述的规范
* docs: 更新测试数据生成器功能设计文档
* refactor: 删除死代码
* docs: 统一测试数据生成器文档,移除 SQL/TypeScript 导出,修复接口命名不一致
* feat: 实现测试数据生成器功能
* docs: 更新 README 和 AGENTS 文档,补充测试数据生成器模块说明
2026-06-07 00:04:53 +08:00
LingandRX a5d86a92c2 develop (#67)
feat: 升级项目 Logo、完善 toast 提示及优化工程化配置

### Features
- **logo**: 替换全套项目图标,更新 wxt.config.ts 浏览器扩展图标配置。

### Bug Fixes
- **ui/toast**: 引入 <Toaster> 组件修复 toast 无法显示问题,并调整位置为正下方。
- **i18n**: 统一将 t() 函数中的 `.` 和 `:` 转换为 `_`,修复国际化文案读取失败问题。
- **context-menu**: 限制 srcUrl 检查仅在二维码图片菜单项生效,避免误判。
- **hooks**: 修复 pre-push 钩子,仅对变更文件进行 eslint 检查,并支持无 upstream 时的回退对比。
- **components**: 修复 CopyButton 动画与组件导入方式,并补齐无障碍属性与测试。
- **async**: 延迟生成二维码以正确渲染 loading 状态,并适配相关异步测试。

### Refactor & Cleans
- **clean**: 清理未使用的组件(ToolCard)、Hook(useDebounce)及多个未使用的工具函数导出。
- **structure**: 统一移动测试文件至 __tests__ 目录,移除不必要的类型导出。

### Documentation
- **docs**: 在 AGENTS.md 中明确规范 Git Commit 必须使用中文描述。
2026-06-06 01:07:27 +08:00
LingandRX c992986789 fix(CopyButton): 修复 review 反馈并补充测试 (#66)
* fix: remove non-functional animation classes from CopyButton
* test(CopyButton): 补充单元测试覆盖边界场景
* fix: unify CopyButton imports to named export across all consumers
* fix(CopyButton): add aria-label for screen reader accessibility
2026-06-05 21:15:48 +08:00
LingandRX 70b799aa2d fix: add missing Toaster component to enable toast display (#65)
The project was calling toast.success/error/warning from sonner in 7 files,
but the <Toaster> component was never rendered in the React tree. This meant
sonner stored toast data internally but no UI was ever displayed to users.

Changes:
- Create src/components/ui/sonner.tsx with shadcn/ui-style Toaster wrapper
  that integrates with ThemeModeProvider for light/dark theme support
- Add <Toaster /> to AppRoot.tsx so it works across all entry points
  (popup, sidepanel, browser-tab)

fix: always convert dots to underscores in i18n t() key lookup

The t() function only converted dots to underscores when the key contained
a colon (namespace:key format). Keys using dot notation like
'messages.copySuccess' were passed directly to chrome.i18n.getMessage()
without dot-to-underscore conversion, causing lookups to fail silently and
return empty strings - resulting in toast notifications with no text.

Now all separators (both ':' and '.') are consistently converted to '_'
regardless of format, matching the messages.json key convention.

fix: position toast at bottom-center instead of bottom-right
2026-06-05 19:29:21 +08:00
LingandRX 58af2af37b fix: 右键菜单 srcUrl 误判、pre-push 变更文件检测、generating 状态不可见 (#64)
* fix: 修复右键菜单 srcUrl 误判、pre-push 全量扫描、generating 状态不可见问题

- contextMenu: srcUrl 检查限定为 qrCode-image 菜单项,避免其他菜单误返回图片 URL
- pre-push: eslint 改为只检查变更文件(git diff 过滤),tsc 保留全项目检查
- useQrCode: confirmGenerate 用 setTimeout 延迟生成,让 loading 状态先被 React 渲染
- tests: 生成相关测试改为 async + waitFor 适配异步 confirmGenerate

* fix(hooks): pre-push 无 upstream 时回退到 origin/main 对比

* fix(hooks): 用 merge-base 替代 rev-parse 修复 pre-push 变更文件检测
2026-06-05 13:10:44 +08:00
107 changed files with 1276 additions and 1351 deletions
+88 -84
View File
@@ -265,12 +265,11 @@ const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
2. 第三方库(图标、UI 库等)
3. 业务 Provider / Context
4. 配置 / 存储
5. i18n
6. 本地页面组件
7. UI 组件
8. 工具函数 / Hook
9. 类型
10. 常量
5. 本地页面组件
6. UI 组件
7. 工具函数 / Hook
8. 类型
9. 常量
```typescript
// 1. React 核心
@@ -284,19 +283,16 @@ import { useThemeMode } from '@/providers/ThemeModeProvider';
// 4. 配置 / 存储
import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage';
// 5. i18n
import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
// 6. 本地组件
// 5. 本地组件
import TextMode from './TextMode';
import { ZONES } from './constants';
// 7. UI 组件
// 6. UI 组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button';
// 8. 工具函数 / Hook
// 7. 工具函数 / Hook
import { cn } from '@/lib/utils';
import { useLazyTranslation } from '@/utils/useLazyTranslation';
// 9. 类型
import { useStorageState } from '@/utils/useStorageState';
// 8. 类型
import type { PageType, StorageSchema } from '@/types/storage';
```
@@ -401,22 +397,22 @@ className={cn(
## 5. 错误处理
### 5.1 工具函数:结果对象模式
### 5.1 工具函数:可恢复错误返回可判断结果
工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象:
可恢复的解析/校验错误应返回可判断的结果,避免工具层直接弹 Toast。确需保留底层异常的函数
(如 `formatJson` / `minifyJson`)必须在页面 Hook 或 UI 层捕获并转换为用户提示:
```typescript
// ✅ 结果对象模式
export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
// ✅ 可恢复校验返回错误消息,调用方据此展示 UI
export function validateJson(text: string): string | null {
if (!text.trim()) {
return null;
}
try {
...
return { html, originalLength, htmlLength, hasError: false };
} catch (error) {
return {
html: '', ...
hasError: true,
error: error instanceof Error ? error.message : 'Markdown 解析失败',
};
JSON.parse(text.trim());
return null;
} catch (e) {
return e instanceof SyntaxError ? e.message : 'Invalid JSON';
}
}
```
@@ -560,7 +556,7 @@ describe('SwitchButtonGroup 组件', () => {
- 使用 `vi.mock()` 进行模块级 Mock
- 使用 `vi.fn()` 进行函数级 Mock
- 使用 `vi.useFakeTimers()` 控制时间
- **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、i18n、matchMedia 等)
- **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、matchMedia 等)
```typescript
// ✅ 模块级 Mock
@@ -658,7 +654,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... }
```
src/utils/useStorageState.ts — Chrome Storage 状态持久化
src/utils/useLazyTranslation.tsi18n 懒加载
src/utils/syncSnapshot.ts localStorage 快照读取
src/utils/useContextMenuData.ts — 右键菜单数据
src/utils/useDebounce.ts — 防抖
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook
@@ -667,47 +663,50 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
---
## 9. 国际化规范
## 9. UI 文案规范
### 9.1 翻译键格式
项目已移除 `chrome.i18n`,所有 UI 文案直接在代码中使用中文。
- 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
### 9.1 功能元数据
### 9.2 翻译文件结构
```
i18n/locales/{zh,en}/common.json — 全局通用翻译
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
```
### 9.3 使用方式
功能名称与描述在 `config/features.tsx``FEATURES` 数组中定义:
```typescript
// ✅ 页面组件 — 使用 useLazyTranslation
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function Index() {
const { t } = useLazyTranslation('timestamp');
return <h1>{t('timestamp:title')}</h1>;
}
// ✅ 全局组件 — 使用 useTranslation
import { useTranslation } from 'react-i18next';
export function TopBar() {
const { t } = useTranslation(['common', 'features']);
return <span>{t('common:settings')}</span>;
{
key: 'timestamp',
label: '时间戳转换',
description: '日期与时间戳互转',
defaultVisible: true,
components: { popup: TimestampPage, sidepanel: TimestampPage, tab: TimestampPage },
}
```
### 9.4 添加新翻译
Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description`
1.`i18n/locales/{zh,en}/features.json` 添加功能标题和描述
2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间
### 9.2 页面与组件文案
- 页面标题、按钮、提示信息等直接在 JSX 或 `constants.ts` 中写中文
- 错误消息可在 Hook 中定义,或使用常量映射
- Toast 通知使用 `sonner``toast()`,文案写在调用处或常量中
```typescript
// ✅ 页面组件 — 直接写中文
export default function Index() {
return <h1 className="font-bold text-sm"></h1>;
}
// ✅ 常量文件 — 可复用文案
export const ERROR_MESSAGES = {
invalidInput: '输入格式无效',
conversionFailed: '转换失败',
} as const;
```
### 9.3 添加新功能文案
1.`config/features.tsx` 填写 `label``description`
2. 在页面组件、`constants.ts` 或 Hook 中编写 UI 文案
3. 扩展名称与描述在 `wxt.config.ts``manifest` 中维护
---
@@ -760,6 +759,18 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState(
);
```
### 10.4 首屏快照与初始化防覆盖
Chrome Storage 读取是异步的。项目通过 `localStorage` 快照(键名 `snapshot/{storageKey}`)提供同步初始值,消除首屏闪烁。
| 模块 | 快照工具 | 防覆盖机制 |
| ---- | -------- | ---------- |
| `RouterProvider` | `syncSnapshot.ts` | `canPersistRef`(加载成功后才写入)、`hasUserNavigatedRef`(用户导航后不被 storage 覆盖) |
| `useStorageState` | `syncSnapshot.ts` | `loadSucceededRef``userModifiedRef` 为 true 时才写入 |
| `ThemeModeProvider` | `themeSnapshot.ts` | `hasUserSetMode`(用户切换主题后不被 storage 覆盖) |
新增持久化状态时,应遵循相同模式:同步快照作初始 state → 异步加载 storage → 加载成功或用户修改后才允许写入。
---
## 11. 页面开发规范
@@ -842,24 +853,21 @@ export default function DashboardPage() { ... }
#### 组件职责
`index.tsx` 只负责件事:
`index.tsx` 只负责件事:
1. **获取翻译函数**`useI18n`
2. **调用业务 Hook** 获取状态和操作方法
3. **渲染 UI 布局**(纯展示,无业务逻辑)
1. **调用业务 Hook** 获取状态和操作方法
2. **渲染 UI 布局**(纯展示,无业务逻辑)
```typescript
// ✅ 标准页面入口模板
import { useI18n } from '@/utils/chromeI18n';
import { useFeatureName } from './useFeatureName';
export default function Index() {
const { t } = useI18n('featureName');
const { state, actions } = useFeatureName();
return (
<div className="p-4 w-full flex flex-col space-y-4 select-none">
{/* 纯 UI 渲染 */}
{/* 纯 UI 渲染,文案直接写中文 */}
</div>
);
}
@@ -903,25 +911,22 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
```typescript
export function useFeatureName(): UseFeatureNameReturn {
// 1. i18n
const { t } = useI18n('featureName');
// 2. 基础 stateuseState
// 1. 基础 stateuseState
const [mode, setMode] = useState<Mode>('default');
const [input, setInput] = useState('');
// 3. 持久化 stateuseStorageState
// 2. 持久化 stateuseStorageState
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode);
// 4. 衍生数据(useMemo)— 响应式计算管线
// 3. 衍生数据(useMemo)— 响应式计算管线
const result = useMemo(() => {
// 自动计算,无需手动点击"转换"按钮
}, [input, mode]);
// 5. 事件处理(useCallback
// 4. 事件处理(useCallback
const handleAction = useCallback(() => { ... }, [deps]);
// 6. 副作用(useEffect)— 防抖、初始化、清理
// 5. 副作用(useEffect)— 防抖、初始化、清理
useEffect(() => { ... }, [deps]);
// 7. 右键菜单数据(页面需要时)
@@ -991,7 +996,7 @@ interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
// ✅ 使用 React.memo + displayName
const ResultView = React.memo(
({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => {
const { t } = useI18n('featureName');
// 文案直接写中文或使用 constants
// ...
},
);
@@ -1001,7 +1006,7 @@ export default ResultView;
#### 子组件内可以使用 Hook
子组件可以独立调用 `useI18n``useSnackbar` 等全局 Hook**不需要**通过 props 从父组件传递翻译函数或 toast 方法
子组件可以独立调用 `useRouter``useThemeMode``toast` 等全局 Hook/API,**不需要**通过 props 从父组件传递。
---
@@ -1038,7 +1043,6 @@ const isValidMode = (val: unknown): val is PageMode =>
typeof val === 'string' && (VALID_MODES as readonly string[]).includes(val);
export default function Index() {
const { t } = useI18n('featureName');
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'modeA', isValidMode);
return (
@@ -1046,8 +1050,8 @@ export default function Index() {
<SwitchButtonGroup
value={pageMode}
options={[
{ value: 'modeA', label: t('feature:modeA') },
{ value: 'modeB', label: t('feature:modeB') },
{ value: 'modeA', label: '模式 A' },
{ value: 'modeB', label: '模式 B' },
]}
onChange={(v: PageMode) => setPageMode(v)}
size="small"
@@ -1078,12 +1082,12 @@ export default function Index() {
新增功能页面时,逐项确认:
1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型
2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、labelKey、icon、三种渲染模式组件)
2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、label、description、icon、三种渲染模式组件)
3. ✅ 创建页面目录,使用 `Index` 作为组件名
4. ✅ 业务逻辑提取到 `useXxx.ts` Hookindex.tsx 不超过 150 行)
5. ✅ 需要持久化的 UI 状态使用 `useStorageState`
6. ✅ 常量 ≥3 个时提取到 `constants.ts`
7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译
7. ✅ 在页面组件或 `constants.ts` 中编写 UI 文案
8. ✅ 创建 `__tests__/index.test.tsx` 测试文件
9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过
@@ -1103,8 +1107,8 @@ export default function Index() {
| `src/hooks/` | 自定义 React Hooks |
| `src/utils/` | 工具函数与服务抽象 |
| `src/types/` | TypeScript 类型声明 |
| `src/lib/` | 通用工具函数(cn、utils |
| `public/` | 静态资源 |
| `src/lib/` | 通用工具函数与生成器库cn、utils、generators |
| `public/` | 静态资源(图标等) |
---
-141
View File
@@ -1,141 +0,0 @@
# 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`
- Prettier100 字符宽、单引号、尾逗号 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.*` APIstorage、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`
+103 -30
View File
@@ -1,6 +1,6 @@
# AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown、测试数据生成器等测试效率工具。
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。
## 核心命令
@@ -16,30 +16,64 @@ npm run typecheck # tsc --noEmit
npm run test # vitest run (单次执行)
npm run test:watch # vitest 监视模式
npm run test:coverage # 带覆盖率的测试
npx vitest run path/to/file.test.ts # 运行单个测试文件
npx wxt prepare # 重新生成 .wxt/ 类型声明(npm install 时 postinstall 会自动执行)
```
修改 `package.json` 或首次克隆仓库后需执行 `npm install`,会自动触发 `postinstall``wxt prepare`
## 验证流程
详见 [CI 配置](./.github/CI.md)
详见 [CI 配置](./.github/CI.md)。本地与 CI 的检查层次如下。
### Pre-commit`.husky/pre-commit`
1. 后台运行 `tsc --noEmit`(不阻塞提交,结果输出到 stderr)
2. 前台运行 `lint-staged`
- 代码文件 (`*.{ts,tsx,js,jsx,mjs}`)`eslint --fix --max-warnings=0`,再 `prettier --write`
- 其他文件 (`*.{json,css,scss,md}`)`prettier --write`
### Pre-push`.husky/pre-push`
1. 全项目 `tsc --noEmit`(阻塞推送)
2. 对本次推送相对 upstream(或 `origin/main`)变更的 `*.{ts,tsx,js,jsx,mjs}` 文件运行 `eslint --max-warnings=0`
### CIGitHub Actions
1. `setup` — 安装依赖
2. 并行 `lint``typecheck`(含 `wxt prepare`)、`test`
3. `build` — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过)
## 项目结构
```
src/ # 源代码根目录
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils
workers/ # Web Worker(数据生成等耗时任务)
public/ # 静态资源(图标、_locales 等
src/ # 源代码根目录
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
layout/ # 应用壳层布局(TopBar 导航、搜索、主题切换)
pages/ # 功能页面组件 (懒加载)
components/ # 可复用 UI 组件
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
providers/ # React Context (Router, Theme 等)
hooks/ # 自定义 React Hooks
utils/ # 工具函数与服务抽象
types/ # TypeScript 类型声明
lib/ # 通用工具函数(cn、utils)及数据生成器定义
workers/ # Web Worker(数据生成等耗时任务
spec/ # 功能规格、修复方案与验收标准(见 spec/README.md
public/ # 静态资源(图标等)
.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑)
.output/ # 生产构建输出目录
```
### layout/
应用壳层,与 popup / sidepanel / tab 入口绑定。当前含 `TopBar/`(搜索、主题切换、返回导航、「在标签页打开」),遵循与 `pages/` 相同的 UI + Hook 模式。详见 [layout/README.md](./src/layout/README.md)。
### spec/
功能规格与验收标准文档,重大改动前优先查阅。索引见 [spec/README.md](./spec/README.md)。
### 页面组件模式
典型功能页面遵循 **UI + Hook 分离** 模式。详见 [CODING_STANDARDS.md § 11](./.github/CODING_STANDARDS.md#11-页面开发规范)。
@@ -59,23 +93,54 @@ src/pages/FeatureName/
-`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
- 复杂页面可增加 `contexts/``hooks/``components/` 子目录
### 测试数据生成器模块
```
src/pages/TestDataGenerator/
├── index.tsx # 主页面(字段配置 + 标签页切换)
├── hooks/useGenerator.ts # Web Worker 管理 Hook(创建、复用、通信、销毁)
└── components/
├── FieldList.tsx # 字段列表(虚拟滚动 + @dnd-kit 拖拽排序 + 规则保存)
├── FieldItem.tsx # 字段卡片展示
├── FieldEditor.tsx # 字段编辑器(名称校验、生成器选择、参数配置)
├── GeneratorSelector.tsx # 生成器选择器(分类 + 搜索)
├── GeneratorConfig.tsx # 生成器参数表单(动态渲染 string/number/boolean/select/array
├── GenerateOptions.tsx # 生成选项(数量、格式)
├── GenerateButton.tsx # 生成按钮 + 进度条
├── DataPreview.tsx # 示例数据预览(JSON 语法高亮)
├── ResultPanel.tsx # 生成结果状态面板
├── ExportPanel.tsx # 导出面板(复制/下载 JSON/CSV)
└── RuleManager.tsx # 规则管理(CRUD、搜索、导入/导出)
src/utils/
├── ruleStorage.ts # 规则持久化存储(localStorage
└── dataExporter.ts # 数据导出工具(JSON/CSV 转换、下载、剪贴板)
src/lib/generators/ # 内置生成器定义(个人信息、企业、技术、基础类型)
src/workers/
└── generator.worker.ts # 数据生成 Web Worker
src/types/
└── testDataGenerator.ts # 类型定义(FieldConfig, DataRule, GeneratorDefinition 等)
```
## 关键架构决策
**路由**: 不使用 React Router。通过 `src/config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 browser-tab(浏览器新标签页,通过 `open_in_tab` 打开)。
渲染对应组件。支持三种渲染模式:popup(弹窗)、sidepanel(侧边栏)和 tab(浏览器新标签页,TopBar「在标签页打开」调用 `openExtensionPage('popup.html', { mode: 'tab' })`,由 `getEntryPointType()` 根据 URL 参数 `mode=tab` 识别)。
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 快照消除首屏闪烁。
使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 快照`snapshot/{key}`消除首屏闪烁。
异步加载完成前禁止写入 storage(`RouterProvider``canPersistRef``useStorageState``loadSucceededRef`),避免默认值覆盖已有数据。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
**路径别名**: `@/` 映射到项目根目录 (已在 tsconfig 和 vitest.config 中配置)
**路径别名**: `@/` 映射到 `src/` 目录(已在 `.wxt/tsconfig.json``vitest.config.ts` 中配置)。项目根目录使用 `@@/`
**浏览器兼容**: 优先使用 `wxt/browser` 导出的 `browser` 对象,而非原生 `chrome` API。
**代码分割**: `wxt.config.ts` 通过 `manualChunksForHtmlOnly()` 自动分组依赖(vendor-react、vendor-qr、vendor-dnd 等),无需手动配置。
## 测试环境
- 环境: jsdom
@@ -87,15 +152,24 @@ src/pages/FeatureName/
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## UI 文案
项目已移除 `chrome.i18n`UI 文案直接在代码中使用中文。
- **功能元数据**: `src/config/features.tsx``FEATURES` 数组定义 `label``description`(用于 Dashboard 卡片与搜索)
- **页面文案**: 在组件 JSX、`constants.ts` 或 Hook 中直接写中文
- **Manifest 文案**: 扩展名称与描述在 `wxt.config.ts``manifest` 中维护
- **Toast / 错误提示**: 在 Hook 或 `constants.ts` 中定义,使用 `sonner``toast()` 展示
## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、翻译键、图标、三种渲染模式的组件)
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、label、description、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件
- `use{FeatureName}.ts` — 业务逻辑 Hook
- `constants.ts` — 常量(可选)
4. 如需新权限,更新 `wxt.config.ts``manifest.permissions`;如有不使用的权限,需移除
- `index.tsx` — UI 组件(纯展示)
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量定义(可选,≥3 个常量时创建
4. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
5. 添加对应的单元测试
## 代码规范
@@ -105,10 +179,9 @@ src/pages/FeatureName/
- 样式: 使用 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 路径)
- **Git Commit**: 必须使用中文描述,遵循 Conventional Commits 规范(如 `fix(组件名): 描述``feat(功能名): 描述`
- **测试维护**: 新增/编辑已有功能/组件时,需要针对相关的测试进行更新
- 格式: Prettier [配置](./.prettierrc)
- ESLint 使用 `typescript-eslint``projectService: true`
- Git Commit: 使用中文描述,遵循 [Conventional Commits 规范](https://www.conventionalcommits.org/zh-hans/v1.0.0/)
## 关键外部库(非显而易见的)
@@ -116,4 +189,4 @@ src/pages/FeatureName/
- `@dnd-kit` — 拖拽排序(用于页面顺序管理和字段列表排序)
- `qrious` + `qr-scanner` — 二维码生成与解析
- `dayjs` — 日期处理(时间戳转换)
- `sonner` — Toast 通知(替代传统 snackbar
- `sonner` — Toast 通知
+1 -1
View File
@@ -1 +1 @@
@AGENT.md
@AGENTS.md
+3 -4
View File
@@ -81,7 +81,7 @@
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **国际化**: i18next + react-i18next
- **UI 文案**: 组件内直接使用中文字符串;功能名称与描述定义在 `src/config/features.tsx`
- **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
@@ -107,8 +107,8 @@
│ ├── hooks/ # 自定义 React Hooks
│ ├── utils/ # 工具函数与服务抽象
│ ├── types/ # TypeScript 类型声明
│ └── lib/ # 通用工具函数 (cn, utils 等)
├── public/ # 静态资源 (图标、manifest 资源等)
│ └── lib/ # 通用工具函数与生成器库 (cn, utils, generators 等)
├── public/ # 静态资源 (图标等)
├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理
```
@@ -155,7 +155,6 @@
- `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能.
- `contextMenus`: 注册右键菜单,支持快捷操作.
- `alarms`: 管理后台定时任务(如清理后自动刷新).
## 浏览器支持
+2 -5
View File
@@ -724,17 +724,14 @@ src/
```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-4 w-full flex flex-col space-y-4 select-none">
{/* 页面内容 */}
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
<h4 className="font-bold text-sm tracking-tight">{t('newTool:title')}</h4>
<h4 className="font-bold text-sm tracking-tight"></h4>
</div>
</div>
);
+11 -1
View File
@@ -2,7 +2,7 @@
> 版本: v1.0
> 创建时间: 2024-01-20
> 状态: 待 Review
> 状态: 已实现(见 `src/pages/TestDataGenerator/`、`src/lib/generators/`、`src/workers/generator.worker.ts`
## 产品定位
@@ -19,3 +19,13 @@
- [规则管理](./rule-management.md)
- [界面设计](./ui-design.md)
- [技术实现](./technical-implementation.md)
## 当前实现入口
- 页面入口:`src/pages/TestDataGenerator/index.tsx`
- Worker`src/workers/generator.worker.ts`
- 生成器库:`src/lib/generators/`
- 规则存储:`src/utils/ruleStorage.ts`
- 导出工具:`src/utils/dataExporter.ts`
实现约束与任务完成状态记录在 [TASKS.md](./TASKS.md)。
+1
View File
@@ -17,3 +17,4 @@
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
- 图标格式推荐使用 PNG,确保透明背景
- UI 文案不在此目录维护,见 `src/config/features.tsx` 与各页面组件
+2 -1
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { copyTextToClipboard } from '@/utils/clipboard';
import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
@@ -53,7 +54,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
aria-label={tooltip ?? '复制'}
variant={variant}
size={size}
className={className}
className={cn(className, copied && 'text-emerald-500')}
{...props}
>
{copied ? (
@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// unmock the globally-mocked component so we test the real implementation
vi.unmock('@/components/CopyButton');
vi.mock('@/utils/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { CopyButton } from '@/components/CopyButton';
import { copyTextToClipboard } from '@/utils/clipboard';
import { toast } from 'sonner';
const mockedCopy = vi.mocked(copyTextToClipboard);
const mockedToast = vi.mocked(toast);
describe('CopyButton', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="hello world" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalledWith('hello world');
expect(mockedToast.success).toHaveBeenCalled();
});
it('复制成功后图标切换为 Check1.5 秒后恢复', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="test" />);
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
await user.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('button').className).toContain('text-emerald');
});
// 1.5 秒后样式恢复
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(screen.getByRole('button').className).not.toContain('text-emerald');
});
});
it('复制空文本时弹出 error toast', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).not.toHaveBeenCalled();
expect(mockedToast.error).toHaveBeenCalled();
});
it('复制失败时弹出 error toast', async () => {
mockedCopy.mockResolvedValue(false);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="something" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalledWith('something');
expect(mockedToast.error).toHaveBeenCalled();
});
// ==================== 新增测试 ====================
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
render(<CopyButton text="initial" />);
const button = screen.getByRole('button');
expect(button.className).not.toContain('text-emerald');
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
expect(button).toHaveAttribute('aria-label');
});
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', '自定义提示');
expect(button).toHaveAttribute('aria-label', '自定义提示');
});
it('className 被正确透传到按钮', () => {
render(<CopyButton text="class-test" className="my-custom-class" />);
const button = screen.getByRole('button');
expect(button.className).toContain('my-custom-class');
});
it('点击事件阻止冒泡', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<CopyButton text="stop-propagation" />
</div>,
);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalled();
expect(parentClick).not.toHaveBeenCalled();
});
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const { unmount } = render(<CopyButton text="unmount-test" />);
await user.click(screen.getByRole('button'));
// 在 1.5 秒超时到期前卸载组件
act(() => {
vi.advanceTimersByTime(500);
});
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
expect(() => unmount()).not.toThrow();
// 前进剩余时间,确认没有异常
act(() => {
vi.advanceTimersByTime(2000);
});
});
it('快速连续点击不会创建多个重叠定时器', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="rapid-click" />);
const button = screen.getByRole('button');
// 快速点击 3 次
await user.click(button);
await user.click(button);
await user.click(button);
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
expect(mockedCopy).toHaveBeenCalledTimes(3);
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
// advance 1.5 秒后,copied 状态应恢复为 false
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(button.className).not.toContain('text-emerald');
});
});
it('其他 button props 通过 ...props 透传', () => {
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('data-testid', 'copy-btn');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('id', 'copy-button-id');
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export default function RouterContainer() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" />
</div>
<h3 className="text-sm font-semibold text-foreground">{'页面未找到'}</h3>
<h3 className="text-sm font-semibold text-foreground"></h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
</p>
+3 -8
View File
@@ -1,7 +1,7 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { toast } from 'sonner';
import { CopyButton } from '@/components/CopyButton';
import { Button } from './ui/button';
@@ -44,7 +44,6 @@ export interface TextInputAreaProps extends Omit<
onClear?: () => void;
}
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
function ActionButton({
action,
value,
@@ -126,7 +125,6 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const textArea = internalRef.current;
if (!textArea) return;
// 重置高度计算
textArea.style.height = 'auto';
const computedMin = minRows * 24;
@@ -255,7 +253,6 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{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
@@ -268,16 +265,15 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
))}
</div>
{/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && (
<CopyButton text={value} tooltip={'复制内容'} size="sm" className="h-7 w-7 p-1" />
<CopyButton text={value} tooltip="复制内容" size="sm" className="h-7 w-7 p-1" />
)}
{showClear && value && !disabled && !readOnly && (
<Button
type="button"
onClick={handleClear}
aria-label={'清空'}
aria-label="清空"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
@@ -290,7 +286,6 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
)}
</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}
+3 -19
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// unmock the globally-mocked component so we test the real implementation
@@ -68,15 +68,10 @@ describe('CopyButton', () => {
expect(mockedToast.error).toHaveBeenCalled();
});
// ==================== 新增测试 ====================
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
it('初始渲染时带有默认 aria-label', () => {
render(<CopyButton text="initial" />);
const button = screen.getByRole('button');
expect(button.className).not.toContain('text-emerald');
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
expect(button).toHaveAttribute('aria-label');
expect(screen.getByRole('button')).toHaveAttribute('aria-label', '复制');
});
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
@@ -119,15 +114,12 @@ describe('CopyButton', () => {
await user.click(screen.getByRole('button'));
// 在 1.5 秒超时到期前卸载组件
act(() => {
vi.advanceTimersByTime(500);
});
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
expect(() => unmount()).not.toThrow();
// 前进剩余时间,确认没有异常
act(() => {
vi.advanceTimersByTime(2000);
});
@@ -141,23 +133,15 @@ describe('CopyButton', () => {
const button = screen.getByRole('button');
// 快速点击 3 次
await user.click(button);
await user.click(button);
await user.click(button);
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
expect(mockedCopy).toHaveBeenCalledTimes(3);
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
// advance 1.5 秒后,copied 状态应恢复为 false
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(button.className).not.toContain('text-emerald');
});
});
it('其他 button props 通过 ...props 透传', () => {
@@ -4,13 +4,13 @@ import EmptyPlaceholder from '@/components/EmptyPlaceholder';
describe('EmptyPlaceholder 组件', () => {
it('应渲染字符串提示文本', () => {
render(<EmptyPlaceholder>{'请输入内容'}</EmptyPlaceholder>);
render(<EmptyPlaceholder></EmptyPlaceholder>);
expect(screen.getByText('请输入内容')).toBeInTheDocument();
});
it('应应用规范空状态容器样式', () => {
const { container } = render(<EmptyPlaceholder>{'提示'}</EmptyPlaceholder>);
const { container } = render(<EmptyPlaceholder></EmptyPlaceholder>);
const placeholder = container.firstChild;
expect(placeholder).toHaveClass(
@@ -24,14 +24,14 @@ describe('EmptyPlaceholder 组件', () => {
it('应支持 className 自定义容器样式', () => {
const { container } = render(
<EmptyPlaceholder className="flex-1 min-h-[320px]">{'提示'}</EmptyPlaceholder>,
<EmptyPlaceholder className="flex-1 min-h-[320px]"></EmptyPlaceholder>,
);
expect(container.firstChild).toHaveClass('flex-1', 'min-h-[320px]');
});
it('应支持 messageClassName 自定义文本样式', () => {
render(<EmptyPlaceholder messageClassName="text-sm max-w-none">{'提示'}</EmptyPlaceholder>);
render(<EmptyPlaceholder messageClassName="text-sm max-w-none"></EmptyPlaceholder>);
expect(screen.getByText('提示')).toHaveClass('text-sm', 'max-w-none');
});
-51
View File
@@ -1,51 +0,0 @@
# entrypoints/
WXT 框架要求的扩展生命周期入口点,对应 Chrome Extension 的各个上下文。
## 入口文件
| 文件 | 用途 |
| ------------------------------- | --------------------------------------------------------------------------------------------- |
| `background.ts` | Service Worker 入口:注册右键菜单、监听菜单点击、处理消息通信、管理侧边栏状态、注入主环境脚本 |
| `content.ts` | Content Script 入口:注入所有页面(`<all_urls>`),在 `document_end` 时初始化消息处理器 |
| `rightClickRestorer.content.ts` | 专用 Content Script:处理右键菜单恢复功能,注入浮动状态徽章 |
## 子目录
### popup/
Popup 弹窗页面(点击扩展图标弹出)。
| 文件 | 用途 |
| ------------ | ------------------------------------------------------------------------------------- |
| `index.html` | HTML 入口 |
| `main.tsx` | React 挂载点 |
| `App.tsx` | 根组件,组装 `RouterProvider` + `layout/TopBar` + `ErrorBoundary` + `RouterContainer` |
### sidepanel/
侧边栏页面,结构与 popup 类似,额外通知 background 侧边栏开启/关闭状态。
### options/
设置页面,支持:
- 拖拽排序功能顺序(`@dnd-kit`
- 功能可见性管理(显示/隐藏)
- Popup/Sidepanel/Tab 三种模式独立配置
### content/
Content Script 内部分模块:
| 文件 | 用途 |
| ----------------------- | ------------------------------------------------------------------- |
| `messageHandler.ts` | 消息处理器初始化入口 |
| `contextMenuHandler.ts` | 右键菜单点击事件处理,执行时间戳转换/文本统计并通过 UI Popover 展示 |
| `uiPopover.ts` | 在页面中注入浮层 Popover UI,展示右键菜单操作结果 |
## 架构说明
- `background.ts` 是扩展的核心协调者,处理跨上下文通信
- `content.ts` 注入到所有页面,负责接收和处理来自 background 的消息
- `popup/``sidepanel/``options/` 共享同一套页面组件(来自 `pages/`),通过 `RouterProvider` 的不同配置实现独立路由
+2 -5
View File
@@ -41,14 +41,11 @@ export default defineBackground(() => {
try {
await browser.action.openPopup();
} catch (err) {
console.warn(
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err,
);
console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,数据已暂存:', err);
}
});
// 监听扩展图标点击事件,安全激活侧边栏
// 扩展图标点击时打开侧边栏
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
try {
@@ -4,18 +4,17 @@ import { getTextStats } from '@/utils/textStatistics';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function convertTimestamp(input: string): string {
const invalidText = '无效时间戳';
const num = Number(input.trim());
if (isNaN(num)) {
return invalidText;
return '无效时间戳';
}
// 1e12 判定毫秒/秒级时间戳兼容
// 1e12 区分毫秒/秒级时间戳
const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
if (isNaN(d.getTime())) {
return invalidText;
return '无效时间戳';
}
const year = d.getFullYear();
-3
View File
@@ -1,11 +1,8 @@
import ReactDOM from 'react-dom/client';
import { applyThemeFromSnapshot } from '@/utils/themeSnapshot';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import App from './App.tsx';
applyThemeFromSnapshot();
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<App />
-3
View File
@@ -1,11 +1,8 @@
import ReactDOM from 'react-dom/client';
import { applyThemeFromSnapshot } from '@/utils/themeSnapshot';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import App from './App.tsx';
applyThemeFromSnapshot();
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<App />
+2 -6
View File
@@ -1,4 +1,3 @@
import React from 'react';
import { type FeatureConfig } from '@/config/features';
import { cn } from '@/lib/utils';
@@ -8,7 +7,7 @@ interface SearchResultItemProps {
onSelect: (feature: FeatureConfig) => void;
}
const SearchResultItem = React.memo(({ feature, selected, onSelect }: SearchResultItemProps) => {
export default function SearchResultItem({ feature, selected, onSelect }: SearchResultItemProps) {
return (
<li
role="option"
@@ -28,7 +27,4 @@ const SearchResultItem = React.memo(({ feature, selected, onSelect }: SearchResu
</div>
</li>
);
});
SearchResultItem.displayName = 'SearchResultItem';
export default SearchResultItem;
}
-2
View File
@@ -4,8 +4,6 @@ export const SEARCH_HISTORY_DISPLAY = 5;
export const isSearchHistory = (val: unknown): val is string[] =>
Array.isArray(val) && val.every((item) => typeof item === 'string');
/** 根据平台返回搜索框快捷键提示文案 */
export function getSearchShortcutLabel(): string {
if (typeof navigator === 'undefined') return 'Ctrl+K';
return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
}
+4 -13
View File
@@ -8,18 +8,17 @@ import { useTopBar } from './useTopBar';
export default function TopBar() {
const {
searchQuery,
showResults,
searchResults,
recentFeatures,
selectedIndex,
isDashboard,
ThemeIcon,
themeTitle,
showDropdown,
containerRef,
inputRef,
setSearchQuery,
setShowResults,
setSelectedIndex,
handleSearchQueryChange,
handleSearchFocus,
handleSelectFeature,
handleKeyDown,
cycleThemeMode,
@@ -40,14 +39,6 @@ export default function TopBar() {
},
];
const handleSearchQueryChange = (value: string) => {
setSearchQuery(value);
setShowResults(true);
setSelectedIndex(-1);
};
const showDropdown = showResults && (searchQuery.trim() || recentFeatures.length > 0);
return (
<header className="relative z-50 flex h-14 items-center justify-between border-b border-border bg-background px-4">
<div className="flex w-10 items-center justify-start">
@@ -70,7 +61,7 @@ export default function TopBar() {
inputRef={inputRef}
searchQuery={searchQuery}
onSearchQueryChange={handleSearchQueryChange}
onFocus={() => setShowResults(true)}
onFocus={handleSearchFocus}
onKeyDown={handleKeyDown}
onClear={clearSearch}
/>
+25 -21
View File
@@ -9,18 +9,17 @@ import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from '.
export interface UseTopBarReturn {
searchQuery: string;
showResults: boolean;
searchResults: FeatureConfig[];
recentFeatures: FeatureConfig[];
selectedIndex: number;
isDashboard: boolean;
ThemeIcon: typeof Sun;
themeTitle: string;
showDropdown: boolean;
containerRef: RefObject<HTMLDivElement | null>;
inputRef: RefObject<HTMLInputElement | null>;
setSearchQuery: (value: string) => void;
setShowResults: (value: boolean) => void;
setSelectedIndex: (value: number | ((prev: number) => number)) => void;
handleSearchQueryChange: (value: string) => void;
handleSearchFocus: () => void;
handleSelectFeature: (feature: FeatureConfig) => void;
handleKeyDown: (e: React.KeyboardEvent) => void;
cycleThemeMode: () => void;
@@ -90,7 +89,6 @@ export function useTopBar(): UseTopBarReturn {
}, [searchHistory, searchQuery]);
const saveToHistory = (featureKey: string) => {
if (!featureKey.trim()) return;
setSearchHistory((prev) =>
[featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT),
);
@@ -124,18 +122,14 @@ export function useTopBar(): UseTopBarReturn {
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) {
handleSelectFeature(selected);
}
}
} else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]);
}
const items = searchQuery.trim() ? searchResults : displayedHistory;
const feature =
selectedIndex >= 0 && selectedIndex < totalItems
? items[selectedIndex]
: searchQuery.trim() && searchResults.length > 0
? searchResults[0]
: undefined;
if (feature) handleSelectFeature(feature);
} else if (e.key === 'Escape') {
setShowResults(false);
inputRef.current?.blur();
@@ -147,20 +141,30 @@ export function useTopBar(): UseTopBarReturn {
setSelectedIndex(-1);
};
const handleSearchQueryChange = (value: string) => {
setSearchQuery(value);
setShowResults(true);
setSelectedIndex(-1);
};
const handleSearchFocus = () => setShowResults(true);
const showDropdown =
showResults && (searchQuery.trim().length > 0 || displayedHistory.length > 0);
return {
searchQuery,
showResults,
searchResults,
recentFeatures: displayedHistory,
selectedIndex,
showDropdown,
isDashboard: currentPage === 'dashboard',
ThemeIcon,
themeTitle,
containerRef,
inputRef,
setSearchQuery,
setShowResults,
setSelectedIndex,
handleSearchQueryChange,
handleSearchFocus,
handleSelectFeature,
handleKeyDown,
cycleThemeMode,
+32 -3
View File
@@ -4,9 +4,10 @@
## 文件说明
| 文件 | 用途 |
| ---------- | ------------------------------------ |
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
| 文件/目录 | 用途 |
| ------------- | -------------------------------------- |
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
| `generators/` | 测试数据生成器内置生成器库和分类注册表 |
## cn()
@@ -24,3 +25,31 @@ import { cn } from '@/lib/utils';
```
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
## generators/
测试数据生成器的核心生成器库,供 `src/workers/generator.worker.ts` 在后台线程中按字段配置生成数据。
| 文件 | 用途 |
| -------------- | ---------------------------------------------- |
| `index.ts` | 聚合所有生成器,导出分类、查找和搜索函数 |
| `personal.ts` | 个人信息生成器(姓名、邮箱、手机号、身份证等) |
| `business.ts` | 业务数据生成器(订单号、价格、日期、状态等) |
| `technical.ts` | 技术数据生成器(UUID、IPv4、URL |
| `basic.ts` | 基础类型生成器(整数、浮点数、布尔值、字符串) |
使用示例:
```typescript
import { getGeneratorById, searchGenerators } from '@/lib/generators';
const generator = getGeneratorById('chineseName');
const value = generator?.generate({});
const matched = searchGenerators('uuid');
```
约束:
- 新增生成器必须实现 `GeneratorDefinition`,并加入所属分类文件的导出列表。
- 需要唯一值或大批量稳定输出时,优先实现 `generateAtIndex(params, index)`
- 新增分类时同步更新 `generatorCategories`,并确保 `GeneratorSelector` 能展示该分类。
-17
View File
@@ -1,14 +1,6 @@
/**
* 基础类型生成器
* 包含:随机整数、随机浮点数、随机字符串、从列表选择
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random';
/**
* 随机整数生成器
*/
export const randomIntGenerator: GeneratorDefinition = {
id: 'randomInt',
name: '随机整数',
@@ -43,9 +35,6 @@ export const randomIntGenerator: GeneratorDefinition = {
},
};
/**
* 随机浮点数生成器
*/
export const randomFloat: GeneratorDefinition = {
id: 'randomFloat',
name: '随机浮点数',
@@ -85,9 +74,6 @@ export const randomFloat: GeneratorDefinition = {
},
};
/**
* 随机字符串生成器
*/
export const randomString: GeneratorDefinition = {
id: 'randomString',
name: '随机字符串',
@@ -166,9 +152,6 @@ export const randomString: GeneratorDefinition = {
},
};
/**
* 从列表选择生成器
*/
export const fromList: GeneratorDefinition = {
id: 'fromList',
name: '从列表选择',
-47
View File
@@ -1,14 +1,6 @@
/**
* 业务数据生成器
* 包含:订单号、价格、日期、状态、数量、评分、折扣、库存
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random';
/**
* 格式化日期
*/
function formatDate(date: Date, format: string): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -26,9 +18,6 @@ function formatDate(date: Date, format: string): string {
.replace('ss', seconds);
}
/**
* 订单号生成器
*/
export const orderId: GeneratorDefinition = {
id: 'orderId',
name: '订单号',
@@ -68,9 +57,6 @@ export const orderId: GeneratorDefinition = {
},
};
/**
* 价格生成器
*/
export const price: GeneratorDefinition = {
id: 'price',
name: '价格',
@@ -142,9 +128,6 @@ export const price: GeneratorDefinition = {
},
};
/**
* 生成心理定价(如 9.99, 19.9, 99
*/
function generatePsychologicalPrice(min: number, max: number): number {
const patterns = [
() => {
@@ -167,9 +150,6 @@ function generatePsychologicalPrice(min: number, max: number): number {
return price;
}
/**
* 生成真实分布价格(对数正态分布)
*/
function generateRealisticPrice(min: number, max: number): number {
const mean = (Math.log(min) + Math.log(max)) / 2;
const stdDev = (Math.log(max) - Math.log(min)) / 4;
@@ -182,9 +162,6 @@ function generateRealisticPrice(min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* 日期生成器
*/
export const date: GeneratorDefinition = {
id: 'date',
name: '日期',
@@ -235,9 +212,6 @@ export const date: GeneratorDefinition = {
},
};
/**
* 状态生成器
*/
export const status: GeneratorDefinition = {
id: 'status',
name: '状态',
@@ -290,9 +264,6 @@ export const status: GeneratorDefinition = {
},
};
/**
* 数量生成器
*/
export const quantity: GeneratorDefinition = {
id: 'quantity',
name: '数量',
@@ -339,9 +310,6 @@ export const quantity: GeneratorDefinition = {
},
};
/**
* 泊松分布随机数
*/
function poissonRandom(lambda: number): number {
const L = Math.exp(-lambda);
let k = 0;
@@ -353,9 +321,6 @@ function poissonRandom(lambda: number): number {
return k - 1;
}
/**
* 评分生成器
*/
export const rating: GeneratorDefinition = {
id: 'rating',
name: '评分',
@@ -410,9 +375,6 @@ export const rating: GeneratorDefinition = {
},
};
/**
* 偏向高分的随机数(模拟真实评分分布)
*/
function skewedRandom(min: number, max: number): number {
// 使用 Beta 分布模拟评分偏向
const alpha = 3;
@@ -425,9 +387,6 @@ function skewedRandom(min: number, max: number): number {
return min + x * (max - min);
}
/**
* 折扣生成器
*/
export const discount: GeneratorDefinition = {
id: 'discount',
name: '折扣',
@@ -479,9 +438,6 @@ export const discount: GeneratorDefinition = {
},
};
/**
* 生成心理折扣(如 5%, 10%, 20%, 30%
*/
function generatePsychologicalDiscount(min: number, max: number): number {
const psychologicalDiscounts = [5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80];
const validDiscounts = psychologicalDiscounts.filter((d) => d >= min && d <= max);
@@ -491,9 +447,6 @@ function generatePsychologicalDiscount(min: number, max: number): number {
return randomInt(min, max);
}
/**
* 库存生成器
*/
export const stock: GeneratorDefinition = {
id: 'stock',
name: '库存',
-20
View File
@@ -1,17 +1,9 @@
/**
* 生成器库索引
* 导出所有生成器和分类配置
*/
import type { GeneratorDefinition, GeneratorCategory } from '@/types/testDataGenerator';
import { personalGenerators } from './personal';
import { businessGenerators } from './business';
import { technicalGenerators } from './technical';
import { basicGenerators } from './basic';
/**
* 生成器分类配置
*/
export const generatorCategories: GeneratorCategory[] = [
{ id: 'personal', name: '个人信息', icon: 'User' },
{ id: 'business', name: '业务数据', icon: 'Briefcase' },
@@ -19,9 +11,6 @@ export const generatorCategories: GeneratorCategory[] = [
{ id: 'basic', name: '基础类型', icon: 'Hash' },
];
/**
* 所有生成器列表
*/
export const allGenerators: GeneratorDefinition[] = [
...personalGenerators,
...businessGenerators,
@@ -29,23 +18,14 @@ export const allGenerators: GeneratorDefinition[] = [
...basicGenerators,
];
/**
* 根据 ID 获取生成器
*/
export function getGeneratorById(id: string): GeneratorDefinition | undefined {
return allGenerators.find((g) => g.id === id);
}
/**
* 根据分类 ID 获取生成器列表
*/
export function getGeneratorsByCategory(categoryId: string): GeneratorDefinition[] {
return allGenerators.filter((g) => g.categoryId === categoryId);
}
/**
* 搜索生成器
*/
export function searchGenerators(query: string): GeneratorDefinition[] {
const lowerQuery = query.toLowerCase();
return allGenerators.filter(
-42
View File
@@ -1,12 +1,6 @@
/**
* 个人信息生成器
* 包含:中文姓名、邮箱、手机号、身份证号、中文地址、年龄
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random';
// 中文姓氏
const SURNAMES = [
'王',
'李',
@@ -60,7 +54,6 @@ const SURNAMES = [
'阎',
];
// 中文名字常用字
const NAME_CHARS = [
'伟',
'芳',
@@ -114,10 +107,8 @@ const NAME_CHARS = [
'慧',
];
// 邮箱域名
const EMAIL_DOMAINS = ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com', 'hotmail.com'];
// 省份
const PROVINCES = [
'北京市',
'天津市',
@@ -153,7 +144,6 @@ const PROVINCES = [
'新疆维吾尔自治区',
];
// 城市
const CITIES: Record<string, string[]> = {
: ['东城区', '西城区', '朝阳区', '海淀区'],
: ['黄浦区', '徐汇区', '长宁区', '静安区'],
@@ -163,9 +153,6 @@ const CITIES: Record<string, string[]> = {
: ['成都市', '绵阳市', '德阳市', '宜宾市'],
};
/**
* 中文姓名生成器
*/
export const chineseName: GeneratorDefinition = {
id: 'chineseName',
name: '中文姓名',
@@ -179,9 +166,6 @@ export const chineseName: GeneratorDefinition = {
},
};
/**
* 邮箱生成器
*/
export const email: GeneratorDefinition = {
id: 'email',
name: '邮箱',
@@ -208,9 +192,6 @@ export const email: GeneratorDefinition = {
},
};
/**
* 手机号生成器
*/
export const chinesePhone: GeneratorDefinition = {
id: 'chinesePhone',
name: '手机号',
@@ -241,9 +222,6 @@ export const chinesePhone: GeneratorDefinition = {
},
};
/**
* 身份证号生成器
*/
export const idCard: GeneratorDefinition = {
id: 'idCard',
name: '身份证号',
@@ -268,20 +246,12 @@ export const idCard: GeneratorDefinition = {
generate: (params) => {
const regions = ['110101', '310101', '440103', '440305', '510104', '330102'];
const region = params.region === 'random' ? randomPick(regions) : (params.region as string);
// 出生日期 1960-2005年
const year = randomInt(1960, 2005);
const month = String(randomInt(1, 12)).padStart(2, '0');
const day = String(randomInt(1, 28)).padStart(2, '0');
const birthday = `${year}${month}${day}`;
// 顺序码
const sequence = String(randomInt(1, 999)).padStart(3, '0');
// 前17位
const prefix17 = region + birthday + sequence;
// 校验码
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0;
@@ -317,9 +287,6 @@ export const idCard: GeneratorDefinition = {
},
};
/**
* 中文地址生成器
*/
export const chineseAddress: GeneratorDefinition = {
id: 'chineseAddress',
name: '中文地址',
@@ -348,9 +315,6 @@ export const chineseAddress: GeneratorDefinition = {
},
};
/**
* 年龄生成器
*/
export const age: GeneratorDefinition = {
id: 'age',
name: '年龄',
@@ -402,9 +366,6 @@ export const age: GeneratorDefinition = {
},
};
/**
* 正态分布随机数
*/
function normalRandom(min: number, max: number): number {
const mean = (min + max) / 2;
const stdDev = (max - min) / 6;
@@ -417,9 +378,6 @@ function normalRandom(min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
/**
* 人口统计分布随机数(模拟真实年龄分布)
*/
function demographicRandom(): number {
const ageGroups = [
{ min: 0, max: 14, weight: 0.18 },
-31
View File
@@ -1,14 +1,6 @@
/**
* 技术数据生成器
* 包含:UUID、IPv4、URL
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random';
/**
* UUID 生成器
*/
export const uuid: GeneratorDefinition = {
id: 'uuid',
name: 'UUID',
@@ -65,9 +57,6 @@ export const uuid: GeneratorDefinition = {
},
};
/**
* 生成 UUID v4
*/
function generateUUIDv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
@@ -76,9 +65,6 @@ function generateUUIDv4(): string {
});
}
/**
* 生成 UUID v1(简化版本,模拟时间戳)
*/
function generateUUIDv1(): string {
const now = Date.now();
const timeLow = (now & 0xffffffff).toString(16).padStart(8, '0');
@@ -92,9 +78,6 @@ function generateUUIDv1(): string {
return `${timeLow}-${timeMid}-${timeHi.toString(16)}-${clockSeq.toString(16)}-${node}`;
}
/**
* IPv4 生成器
*/
export const ipv4: GeneratorDefinition = {
id: 'ipv4',
name: 'IPv4',
@@ -129,16 +112,10 @@ export const ipv4: GeneratorDefinition = {
},
};
/**
* 生成完全随机 IPv4
*/
function generateRandomIPv4(): string {
return Array.from({ length: 4 }, () => randomInt(0, 255)).join('.');
}
/**
* 生成内网 IPv4
*/
function generatePrivateIPv4(): string {
const ranges = [
{ prefix: '10', second: () => randomInt(0, 255) },
@@ -153,9 +130,6 @@ function generatePrivateIPv4(): string {
return `${range.prefix}.${range.second()}.${randomInt(1, 254)}.${randomInt(1, 254)}`;
}
/**
* 生成公网 IPv4
*/
function generatePublicIPv4(): string {
let first: number;
do {
@@ -165,9 +139,6 @@ function generatePublicIPv4(): string {
return `${first}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`;
}
/**
* URL 生成器
*/
export const url: GeneratorDefinition = {
id: 'url',
name: 'URL',
@@ -237,8 +208,6 @@ export const url: GeneratorDefinition = {
path = `/${randomPick(['about', 'contact', 'products', 'services', 'blog'])}`;
}
}
// 添加查询参数
let query = '';
if (Math.random() > 0.5) {
const params = new URLSearchParams();
@@ -40,12 +40,12 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
handleFileSelect,
maxFileSizeStr,
} = useBase64Converter({ mode });
const handleDirectionChange = (next: Base64ConvertDirection) => {
if (!next || next === direction) return;
if (next === direction) return;
resetAll();
setDirection(next);
};
@@ -80,7 +80,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) safeFileSelect(file);
if (file) void handleFileSelect(file);
}}
onClick={() => fileInputRef.current?.click()}
className={cn(
@@ -99,7 +99,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) safeFileSelect(file);
if (file) void handleFileSelect(file);
}}
/>
{isLoading ? (
@@ -123,7 +123,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{formatBytes(info.size)} · {info.type}
</span>
<span className="text-[11px] font-medium text-primary/80 mt-1">
{'点击或拖拽以替换文件'}
</span>
</div>
) : (
@@ -158,17 +158,17 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
{'Base64 编码结果'}
Base64
</span>
<div className="flex gap-2">
<CopyButton
text={result.rawBase64}
tooltip={'复制纯 Base64'}
tooltip="复制纯 Base64"
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
<CopyButton
text={result.output}
tooltip={'复制 Data URI'}
tooltip="复制 Data URI"
className="h-6 px-2 rounded-md border text-[10px] font-bold"
/>
</div>
@@ -186,14 +186,14 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums">
<span>
{'原始大小'}:{' '}
:{' '}
<span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)}
</span>
</span>
<span className="text-border/60">|</span>
<span>
{'编码大小'}:{' '}
:{' '}
<span className="font-semibold text-foreground/80">
{formatBytes(result.outputBytes)}
</span>
@@ -206,7 +206,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
>
<Trash2 className="w-3.5 h-3.5" />
{'清空'}
</Button>
</div>
</div>
@@ -215,7 +215,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
) : (
<div className="flex flex-col space-y-4">
<TextInputArea
placeholder={'输入需要解码的 Base64 或 data URI...'}
placeholder="输入需要解码的 Base64 或 data URI..."
value={decodeInput}
onChange={setDecodeInput}
externalError={decodeError || undefined}
@@ -1,12 +1,3 @@
/**
* DecodeResultPaper
*
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
* 提取了二者 decode 输出区完全一致的结构:
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
*
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
*/
import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -14,19 +5,12 @@ import { Label } from '@/components/ui/label';
import { formatBytes } from '@/utils/format';
interface DecodeResultPaperProps {
/** 标题文案 */
title: string;
/** 解码后推断的 MIME 类型 */
mimeType: string;
/** 解码后 Blob 的大小(字节) */
blobSize: number;
/** 当前文件名 */
fileName: string;
/** 文件名变更回调 */
onFileNameChange: (name: string) => void;
/** 下载按钮点击回调 */
onDownload: () => void;
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
children?: React.ReactNode;
}
@@ -41,31 +25,20 @@ export default function DecodeResultPaper({
}: DecodeResultPaperProps) {
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">
{'推断的 MIME 类型'}: {mimeType}
</span>
<span className="text-xs text-muted-foreground">
{'解码大小'}: {formatBytes(blobSize)}
</span>
<span className="text-xs text-muted-foreground"> MIME : {mimeType}</span>
<span className="text-xs text-muted-foreground">: {formatBytes(blobSize)}</span>
</div>
{/* 文件名输入 */}
<div className="mb-3">
<Label className="block text-xs font-medium text-muted-foreground mb-1">
{'解码后文件名'}
</Label>
<Label className="block text-xs font-medium text-muted-foreground mb-1"></Label>
<Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} />
</div>
{/* 下载按钮 */}
<Button
variant="default"
onClick={onDownload}
@@ -73,7 +46,7 @@ export default function DecodeResultPaper({
className="w-full rounded-lg font-bold"
>
<Download className="mr-2 h-4 w-4" />
{'下载'}
</Button>
</div>
);
@@ -8,7 +8,7 @@ interface TextModeProps {
onSwitchToImageMode?: () => void;
}
export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
export default function TextMode({ onSwitchToImageMode }: TextModeProps) {
const {
input,
setInput,
@@ -93,16 +93,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
[mode],
);
const safeFileSelect = useCallback(
(file: File) => {
handleFileSelect(file).catch((err) => {
console.error(`Base64 [${mode}] pipeline crash:`, err);
});
},
[handleFileSelect, mode],
);
const decodePipeline = useMemo(() => {
const decodeResult = useMemo(() => {
const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim();
if (!cleanedInput) return { decoded: null, error: null };
@@ -118,8 +109,8 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
}
}, [debouncedDecodeInput]);
const decoded = decodePipeline.decoded;
const decodeError = decodePipeline.error;
const decoded = decodeResult.decoded;
const decodeError = decodeResult.error;
const decodedFileName = useMemo(() => {
if (customFileName) return customFileName;
@@ -144,7 +135,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
decodedFileName,
setCustomFileName,
resetAll,
safeFileSelect,
handleFileSelect,
maxFileSizeStr,
};
}
+8 -13
View File
@@ -4,7 +4,7 @@ import { useContextMenuData } from '@/utils/useContextMenuData';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
const ERROR_MESSAGES: Record<string, string> = {
'Invalid Base64 string': '无效的 Base64 字符串',
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
'检测到二进制数据(如图像),请使用图像选项卡',
@@ -30,7 +30,7 @@ export function useTextMode() {
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
const conversionPipeline = useMemo(() => {
const conversion = useMemo(() => {
const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null };
@@ -38,31 +38,26 @@ export function useTextMode() {
if (direction === 'encode') {
const result = textToBase64(debouncedInput);
return { output: result.output, error: null };
} else {
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
}
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
} catch (e) {
const message = e instanceof Error ? e.message : '';
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
return {
output: '',
error: i18nKey || message || '转换失败',
error: ERROR_MESSAGES[message] || message || '转换失败',
};
}
}, [debouncedInput, direction]);
const output = conversionPipeline.output;
const error = conversionPipeline.error;
const output = conversion.output;
const error = conversion.error;
const placeholder =
direction === 'encode' ? '输入需要编码为 Base64 的文本...' : '输入需要解码的 Base64 字符串...';
const outputLabel = direction === 'encode' ? 'Base64 编码结果' : '解码文本结果';
const showImageHint = useMemo(
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
[direction, input],
);
const showImageHint = direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input);
const handleDirectionChange = (value: 'encode' | 'decode') => {
if (value === direction) return;
+2 -7
View File
@@ -1,8 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getFeatureByKey } from '@/config/features';
import type { DashboardFeatureItem } from '../useDashboard';
import { resolveDashboardFeatures, type DashboardFeatureItem } from '../dashboardFeatures';
import Index from '../index';
const mockNavigateTo = vi.fn();
@@ -13,11 +12,7 @@ vi.mock('../useDashboard', () => ({
}));
function buildFeatureItem(key: 'timestamp' | 'jwt'): DashboardFeatureItem {
const feature = getFeatureByKey(key)!;
return {
key,
feature: feature as DashboardFeatureItem['feature'],
};
return resolveDashboardFeatures([key], [key])[0]!;
}
describe('Dashboard 页面', () => {
-4
View File
@@ -14,10 +14,6 @@ function isDashboardFeature(feature: FeatureConfig): feature is DashboardFeature
return feature.icon != null;
}
/**
* 将页面 key 列表解析为 Dashboard 可展示的工具项。
* 过滤条件:在 visiblePages 中且 feature 配置了 icon。
*/
export function resolveDashboardFeatures(
keys: PageType[],
visiblePages: PageType[],
+3 -3
View File
@@ -9,7 +9,7 @@ export default function Index() {
{showRecent && (
<div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{'最近使用'}
使
</h3>
<div className="flex flex-wrap gap-2">
{recentFeatures.map(({ key, feature }) => {
@@ -37,10 +37,10 @@ export default function Index() {
<div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{'全部工具'}
</h3>
{visibleFeatures.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">{'没有可用的工具'}</p>
<p className="text-sm text-muted-foreground py-4 text-center"></p>
) : (
<div
className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}
-2
View File
@@ -3,8 +3,6 @@ import { useRouter } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage';
import { resolveDashboardFeatures, type DashboardFeatureItem } from './dashboardFeatures';
export type { DashboardFeatureItem } from './dashboardFeatures';
export interface UseDashboardReturn {
visibleFeatures: DashboardFeatureItem[];
recentFeatures: DashboardFeatureItem[];
@@ -5,7 +5,6 @@ import Index from '../index';
describe('JsonTools 页面', () => {
it('应该渲染模式切换按钮', () => {
render(<Index />);
// 基本渲染测试:页面含多个模式切换按钮,应至少渲染一个
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
});
@@ -30,7 +30,7 @@ export default function DiffNavigator({
)}
{...props}
>
<span className="text-xs font-semibold text-muted-foreground/90">{'无差异'}</span>
<span className="text-xs font-semibold text-muted-foreground/90"></span>
</div>
);
}
@@ -43,11 +43,10 @@ export default function DiffNavigator({
)}
{...props}
>
{/* 上一处差异按钮 */}
<button
type="button"
disabled={isFirst}
aria-label={'上一个'}
aria-label="上一个"
onClick={onPrev}
className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -63,11 +62,10 @@ export default function DiffNavigator({
{total}
</span>
{/* 下一处差异按钮 */}
<button
type="button"
disabled={isLast}
aria-label={'下一个'}
aria-label="下一个"
onClick={onNext}
className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -23,11 +23,11 @@ export default function DiffResult({
{...props}
>
<div className="flex-1 min-w-0">
<SectionLabel text={'原始 JSON'} />
<SectionLabel text="原始 JSON" />
<JsonTree node={result.root} side="left" activePath={activePath} />
</div>
<div className="flex-1 min-w-0">
<SectionLabel text={'目标 JSON'} />
<SectionLabel text="目标 JSON" />
<JsonTree node={result.root} side="right" activePath={activePath} />
</div>
</div>
@@ -133,7 +133,6 @@ const UnifiedView = ({ node, depth, activePath }: UnifiedViewProps) => {
);
}
// 容器节点整块渲染处理
if (node.type === 'added') {
return (
<UnifiedRow
@@ -28,12 +28,12 @@ const CONVERT_LABELS: Record<
};
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
translationPrefix: string;
mode: string;
convertFunction: ConvertFunction;
}
export default function JsonConvertSection({
translationPrefix,
mode,
convertFunction,
className,
...props
@@ -41,10 +41,8 @@ export default function JsonConvertSection({
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
const pk = translationPrefix;
const labels = CONVERT_LABELS[pk] || CONVERT_LABELS.yaml;
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
// Debounce input
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
@@ -56,33 +54,25 @@ export default function JsonConvertSection({
return validateJson(debouncedInput);
}, [debouncedInput]);
const conversionPipeline = useMemo(() => {
const { result, runtimeError } = useMemo((): {
result: ConvertResult | null;
runtimeError: string | null;
} => {
const trimmed = debouncedInput.trim();
if (!trimmed || error) return null;
if (!trimmed || error) return { result: null, runtimeError: null };
try {
return convertFunction(debouncedInput);
return { result: convertFunction(debouncedInput), runtimeError: null };
} catch (e) {
// 捕获可能从外部转换器(如 YAML.stringify)中抛出的底层异常
return {
isRuntimeError: true,
errorMessage: e instanceof Error ? e.message : String(e),
result: null,
runtimeError: e instanceof Error ? e.message : String(e),
};
}
}, [debouncedInput, error, convertFunction]);
const runtimeError =
conversionPipeline && 'isRuntimeError' in conversionPipeline
? conversionPipeline.errorMessage
: null;
const result =
conversionPipeline && !('isRuntimeError' in conversionPipeline)
? (conversionPipeline as ConvertResult)
: null;
return (
<div className={cn('w-full flex flex-col gap-4', className)} {...props}>
{/* 输入区 */}
<TextInputArea
placeholder={labels.inputPlaceholder}
value={input}
@@ -95,8 +85,7 @@ export default function JsonConvertSection({
onClear={() => setInput('')}
/>
{/* Result display */}
{result && result.output ? (
{result?.output ? (
<JsonResultPanel
title={labels.outputLabel}
content={result.output}
@@ -18,7 +18,6 @@ export default function JsonFormatSection() {
const [indentSize, setIndentSize] = useState<number>(2);
const [sortKeys, setSortKeys] = useState(false);
// Debounce input
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
@@ -30,40 +29,31 @@ export default function JsonFormatSection() {
return validateJson(debouncedInput);
}, [debouncedInput]);
// Real-time formatting pipeline
const formattedPipeline = useMemo(() => {
const { result, runtimeError } = useMemo((): {
result: JsonFormatResult | null;
runtimeError: string | null;
} => {
const trimmed = debouncedInput.trim();
if (!trimmed || error) return null;
if (!trimmed || error) return { result: null, runtimeError: null };
try {
const options: JsonFormatOptions = { indentSize, sortKeys };
return formatJson(debouncedInput, options);
return { result: formatJson(debouncedInput, options), runtimeError: null };
} catch (e) {
return {
isRuntimeError: true,
errorMessage: e instanceof SyntaxError ? e.message : String(e),
result: null,
runtimeError: e instanceof SyntaxError ? e.message : String(e),
};
}
}, [debouncedInput, error, indentSize, sortKeys]);
const runtimeError =
formattedPipeline && 'isRuntimeError' in formattedPipeline
? formattedPipeline.errorMessage
: null;
const result =
formattedPipeline && !('isRuntimeError' in formattedPipeline)
? (formattedPipeline as JsonFormatResult)
: null;
return (
<div className="w-full flex flex-col gap-4">
{/* 工具控制栏 */}
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
<div className="flex gap-4 items-center w-full">
{/* 缩进配置区 */}
<div className="flex gap-2 items-center shrink-0 select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{'缩进'}
</span>
<SwitchButtonGroup
value={indentSize}
@@ -75,7 +65,6 @@ export default function JsonFormatSection() {
<div className="h-4 w-px bg-border/60" />
{/* 键名排序区 */}
<div
onClick={() => setSortKeys(!sortKeys)}
className="flex items-center gap-2 cursor-pointer select-none group py-1"
@@ -91,15 +80,14 @@ export default function JsonFormatSection() {
htmlFor="sort-keys-checkbox"
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground"
>
{'键名排序'}
</Label>
</div>
</div>
</div>
{/* 满血版输入终端 */}
<TextInputArea
placeholder={'输入需要格式化的 JSON...'}
placeholder="输入需要格式化的 JSON..."
value={input}
onChange={setInput}
externalError={error || runtimeError || undefined}
@@ -110,8 +98,7 @@ export default function JsonFormatSection() {
onClear={() => setInput('')}
/>
{/* 格式化结果流面板展示 */}
{result && result.formatted ? (
{result?.formatted ? (
<JsonResultPanel
title="格式化结果"
content={result.formatted}
@@ -28,7 +28,7 @@ export default function JsonResultPanel({
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span>
{'原始大小'}:{' '}
:{' '}
<span className="font-semibold text-foreground/80">{formatBytes(originalBytes)}</span>
</span>
<span className="text-border/60">|</span>
+2 -10
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; // 用正统的矢量箭头平替原生的字符 '▾' '▸'
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { DiffNode, DiffType } from '../types';
import { cn } from '@/lib/utils';
@@ -82,14 +82,12 @@ const NodeRow = React.memo(
? false
: onActivePath || depth < defaultExpandDepth;
// 当激活路径精准定位到本行时,平滑滚动至容器中心
useEffect(() => {
if (activePath === node.path) {
rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, [activePath, node.path]);
// 占位空行分支:必须加 h-[22px] 锁定绝对等高,防止两侧文本高度塌陷发生高低错位
if (!shouldRenderOnSide(node.type, side)) {
return (
<div
@@ -124,7 +122,6 @@ const NodeRow = React.memo(
return (
<div ref={rowRef} className="w-full flex flex-col">
{/* 大容器开端行 */}
<div
onClick={() => setOverride(expanded ? 'closed' : 'open')}
className={cn(
@@ -135,7 +132,6 @@ const NodeRow = React.memo(
)}
style={indentStyle}
>
{/* 折叠小箭头:升级为精巧的 Lucide SVG 矢量微动效 */}
<span className="w-3.5 h-3.5 flex items-center justify-center text-muted-foreground/80 shrink-0">
{expanded ? (
<ChevronDown className="h-3 w-3" />
@@ -164,7 +160,6 @@ const NodeRow = React.memo(
)}
</div>
{/* 容器子节点递归区 */}
{expanded && (
<div className={indentClass}>
{node.children.map((child, idx) => (
@@ -181,7 +176,6 @@ const NodeRow = React.memo(
</div>
)}
{/* 大容器收尾行 */}
{expanded && (
<div
className="text-muted-foreground/80 font-mono text-xs py-0.5 h-[22px] leading-relaxed"
@@ -195,7 +189,6 @@ const NodeRow = React.memo(
);
}
// 叶子数据行分支
return (
<div
ref={rowRef}
@@ -207,7 +200,7 @@ const NodeRow = React.memo(
)}
style={indentStyle}
>
<span className="w-3.5 shrink-0" /> {/* 与上方的折叠键轴线严格对齐 */}
<span className="w-3.5 shrink-0" />
{!isRoot && (
<span className="text-foreground/90 font-bold tracking-tight">{node.key}:</span>
)}
@@ -231,7 +224,6 @@ export default function JsonTree({
...props
}: JsonTreeProps) {
return (
/* 最外层承载器:统一收拢至标准的 bg-card 与等宽 tabular-nums 控制轴 */
<div
className={cn(
'rounded-xl border border-border bg-card text-card-foreground font-mono text-xs shadow-sm overflow-x-auto min-h-[200px] max-h-[520px] overflow-y-auto p-2.5 tabular-nums select-text',
+7 -7
View File
@@ -66,16 +66,16 @@ export default function Index() {
<div className="flex flex-col md:flex-row gap-4 w-full items-stretch">
<JsonDiffInput
label={'原始 JSON'}
placeholder={'输入原始 JSON...'}
label="原始 JSON"
placeholder="输入原始 JSON..."
value={leftInput}
onChange={setLeftInput}
error={leftError}
minRows={9}
/>
<JsonDiffInput
label={'目标 JSON'}
placeholder={'输入目标 JSON...'}
label="目标 JSON"
placeholder="输入目标 JSON..."
value={rightInput}
onChange={setRightInput}
error={rightError}
@@ -106,11 +106,11 @@ export default function Index() {
) : pageMode === 'format' ? (
<JsonFormatSection />
) : pageMode === 'yaml' ? (
<JsonConvertSection translationPrefix="yaml" convertFunction={yamlConvert} />
<JsonConvertSection mode="yaml" convertFunction={yamlConvert} />
) : pageMode === 'toml' ? (
<JsonConvertSection translationPrefix="toml" convertFunction={tomlConvert} />
<JsonConvertSection mode="toml" convertFunction={tomlConvert} />
) : (
<JsonConvertSection translationPrefix="minify" convertFunction={minifyConvert} />
<JsonConvertSection mode="minify" convertFunction={minifyConvert} />
)}
</div>
);
-7
View File
@@ -11,7 +11,6 @@ import type { ConvertFunction, ViewMode } from './types';
export interface UseJsonToolsReturn {
pageMode: JsonToolsPageMode;
setPageMode: (mode: JsonToolsPageMode) => void;
// Diff mode state
leftInput: string;
rightInput: string;
setLeftInput: (val: string) => void;
@@ -26,7 +25,6 @@ export interface UseJsonToolsReturn {
handlePrev: () => void;
handleNext: () => void;
activePath: string | undefined;
// Convert functions
yamlConvert: ConvertFunction;
tomlConvert: ConvertFunction;
minifyConvert: ConvertFunction;
@@ -35,13 +33,11 @@ export interface UseJsonToolsReturn {
export function useJsonTools(): UseJsonToolsReturn {
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
// Diff inputs
const [leftInput, setLeftInput] = useState('');
const [rightInput, setRightInput] = useState('');
const [debouncedLeft, setDebouncedLeft] = useState('');
const [debouncedRight, setDebouncedRight] = useState('');
// Debounce
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedLeft(leftInput);
@@ -50,7 +46,6 @@ export function useJsonTools(): UseJsonToolsReturn {
return () => clearTimeout(handle);
}, [leftInput, rightInput]);
// Parse debounced inputs
const parseState = useMemo(() => {
const invalidMsg = '无效的 JSON 格式';
return {
@@ -65,7 +60,6 @@ export function useJsonTools(): UseJsonToolsReturn {
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
// Real-time diff computation
const diffResult = useMemo(() => {
const { left, right } = parseState;
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
@@ -88,7 +82,6 @@ export function useJsonTools(): UseJsonToolsReturn {
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
// Convert functions
const yamlConvert: ConvertFunction = useCallback((text: string) => {
const r = jsonToYaml(text);
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
@@ -2,12 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import ImageUploader from '../components/ImageUploader';
// 配置多端一致性常驻桩(WXT 规范)
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 模拟 URL API
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
@@ -171,21 +169,4 @@ describe('ImageUploader 组件', () => {
expect(pasteListeners).toHaveLength(0);
});
});
describe('样式测试', () => {
it('拖拽状态时应应用拖拽样式', () => {
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
it('有文件时应应用有文件样式', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const { container } = render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
});
});
+6 -12
View File
@@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
/** 文本预览的最大显示字符数 */
const TEXT_PREVIEW_MAX_LENGTH = 80;
export default function GeneratePanel() {
@@ -22,13 +21,11 @@ export default function GeneratePanel() {
const isInputStep = generatorState.step === 'input';
const hasText = generatorState.textToEncode.trim().length > 0;
/** 截断文本用于预览显示 */
const getTruncatedText = (text: string) => {
if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text;
return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...';
};
// 输入态:只显示输入框和生成按钮
if (isInputStep) {
return (
<div className="w-full select-none p-0.5">
@@ -40,13 +37,13 @@ export default function GeneratePanel() {
>
<div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'输入 URL 或文本'}
URL
</Label>
<TextInputArea
value={generatorState.textToEncode}
onChange={setTextToEncode}
placeholder={'请输入 URL 或文本内容,将自动生成二维码'}
placeholder="请输入 URL 或文本内容,将自动生成二维码"
showCount={true}
showClear={true}
allowCopy={false}
@@ -64,12 +61,12 @@ export default function GeneratePanel() {
{generatorState.generating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{'生成中...'}
...
</>
) : (
<>
<QrCode className="w-4 h-4 mr-2" />
{'生成二维码'}
</>
)}
</Button>
@@ -79,18 +76,16 @@ export default function GeneratePanel() {
);
}
// 预览态:显示文本预览 + 二维码(紧凑布局,适配popup窗口)
return (
<div className="w-full select-none p-0.5 flex flex-col">
{/* 文本预览区域(固定高度,单行显示) */}
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'原始文本'}
</Label>
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs">
<Pencil className="w-3 h-3 mr-1" />
{'编辑'}
</Button>
</div>
<div
@@ -101,7 +96,6 @@ export default function GeneratePanel() {
</div>
</div>
{/* 二维码预览区域(自适应剩余空间) */}
<QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode}
+9 -25
View File
@@ -1,21 +1,14 @@
import { useCallback, useRef } from 'react';
import { Image, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string;
/** 是否正在拖拽 */
dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void;
}
@@ -27,26 +20,18 @@ const ImageUploader = ({
dragging,
onDraggingChange,
}: ImageUploaderProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
},
[onFileChange],
);
const handleClearFile = useCallback(() => {
const handleClearFile = () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
onClearFile();
toast.success('图片已清除');
}, [previewUrl, onClearFile]);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFileChange(e.target.files[0]);
const file = e.target.files?.[0];
if (file) {
onFileChange(file);
}
};
@@ -64,7 +49,7 @@ const ImageUploader = ({
onDraggingChange(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
handleFileChange(droppedFile);
onFileChange(droppedFile);
}
};
@@ -82,7 +67,6 @@ const ImageUploader = ({
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleInputChange}
@@ -113,7 +97,7 @@ const ImageUploader = ({
</Button>
</div>
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
<span className="block text-xs text-muted-foreground">{'点击更换图片'}</span>
<span className="block text-xs text-muted-foreground"></span>
</div>
) : (
<>
@@ -122,10 +106,10 @@ const ImageUploader = ({
className="w-12 h-12 text-muted-foreground mx-auto mb-2"
/>
<span className="block text-sm text-muted-foreground mb-1">
{'点击、拖拽或粘贴上传二维码图片'}
</span>
<span className="block text-xs text-muted-foreground">
{'支持 PNG、JPG、WEBP、Base64 格式'}
PNGJPGWEBPBase64
</span>
</>
)}
+4 -9
View File
@@ -13,7 +13,6 @@ export default function ParsePanel() {
const hasFile = parserState.selectedFile !== null;
// 全局粘贴事件监听
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
@@ -56,7 +55,6 @@ export default function ParsePanel() {
};
}, [handlePaste]);
// 未上传图片:只显示上传区域
if (!hasFile) {
return (
<div className="w-full select-none p-0.5">
@@ -72,18 +70,16 @@ export default function ParsePanel() {
);
}
// 已上传图片:显示图片预览 + 解析结果
return (
<div className="w-full select-none p-0.5 flex flex-col">
{/* 图片预览区域(小图预览) */}
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'已上传图片'}
</Label>
<Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs">
<RefreshCw className="w-3 h-3 mr-1" />
{'重新上传'}
</Button>
</div>
<div className="flex items-center gap-3 bg-muted/50 rounded-md p-2">
@@ -95,13 +91,12 @@ export default function ParsePanel() {
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p>
{parserState.parsing && (
<p className="text-xs text-primary animate-pulse mt-1">{'解析中...'}</p>
<p className="text-xs text-primary animate-pulse mt-1">...</p>
)}
</div>
</div>
</div>
{/* 解析结果区域 */}
<div
className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
@@ -110,7 +105,7 @@ export default function ParsePanel() {
>
<div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
{'解析结果'}
</Label>
<TextInputArea
+2 -13
View File
@@ -4,15 +4,10 @@ import EmptyPlaceholder from '@/components/EmptyPlaceholder';
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;
}
@@ -24,7 +19,6 @@ const QrCodePreview = ({
className,
...props
}: QrCodePreviewProps) => {
// 空状态下的虚线骨架屏
if (!qrCodeDataUrl) {
return (
<EmptyPlaceholder
@@ -46,11 +40,6 @@ const QrCodePreview = ({
{...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}
@@ -62,12 +51,12 @@ const QrCodePreview = ({
<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">{'下载二维码'}</span>
<span className="truncate text-xs"></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">{'复制二维码'}</span>
<span className="truncate text-xs"></span>
</Button>
</div>
</div>
+12 -9
View File
@@ -12,32 +12,26 @@ function isUrl(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
// 检查是否以 http:// 或 https:// 开头
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return true;
}
// 检查是否为域名格式(包含.且不以特殊字符开头)
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/;
return domainPattern.test(trimmed);
}
/** 检测URL是否为图片格式 */
function isImageUrl(url: string): boolean {
const trimmedUrl = url.trim().toLowerCase();
// 检查是否为 data:image 格式
if (trimmedUrl.startsWith('data:image/')) {
return true;
}
// 检查是否包含图片扩展名
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'];
if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) {
return true;
}
// 检查URL路径中是否包含图片相关关键词
const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/'];
if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) {
return true;
@@ -46,7 +40,6 @@ function isImageUrl(url: string): boolean {
return false;
}
/** 生成二维码的核心逻辑 */
function generateQrCodeDataUrl(text: string): string {
const trimmedText = text.trim();
if (!trimmedText) return '';
@@ -241,7 +234,17 @@ export function useQrCode(): QrCodeContextValue {
const qrCodeDataUrl = generateQrCodeDataUrl(payload);
if (!qrCodeDataUrl) {
if (qrCodeDataUrl) {
setGeneratorState((prev) => ({
...prev,
step: 'preview',
textToEncode: payload,
savedText: payload.trim(),
qrCodeDataUrl,
generating: false,
inputError: '',
}));
} else {
setGeneratorState((prev) => ({
...prev,
step: 'input',
@@ -328,7 +331,7 @@ export function useQrCode(): QrCodeContextValue {
});
parseQrCode(file).catch((err) => {
console.error('Parser standalone task thread exploded:', err);
console.error('解析二维码失败:', err);
});
},
[parseQrCode],
-139
View File
@@ -1,139 +0,0 @@
# pages/
功能页面组件目录,每个子目录对应一个工具页面。
## 目录结构约定
典型页面遵循 **UI + Hook 分离** 模式:
```
pages/FeatureName/
├── index.tsx # 页面 UI(纯展示,使用 shadcn/ui 组件)
├── useFeatureName.ts # 业务逻辑 Hook(状态管理 + 转换逻辑)
├── constants.ts # 常量定义(可选)
├── SubComponent.tsx # 子组件(可选)
└── __tests__/ # 测试文件
└── index.test.tsx
```
## 页面列表
### Dashboard/
仪表盘首页,以卡片网格展示所有可见工具,支持点击导航。
| 文件 | 用途 |
| ----------- | -------------------------- |
| `index.tsx` | 页面组件,渲染工具卡片网格 |
### Timestamp/
时间戳转换工具,支持秒/毫秒级互转、多时区选择、实时时钟。
| 文件 | 用途 |
| -------------------------- | ----------------------------------------------------------------- |
| `index.tsx` | 页面 UI |
| `useTimestampConverter.ts` | 业务逻辑 Hook,包含转换模式、输入、单位、时区状态和响应式计算管线 |
| `LiveClock.tsx` | 实时时钟子组件,`React.memo` 优化 |
| `ResultView.tsx` | 转换结果展示子组件 |
| `constants.ts` | 时区列表等常量 |
### StorageCleaner/
浏览器存储清理工具,支持 Cookie/LocalStorage/SessionStorage/IndexedDB/Cache/SW 清理。
| 文件 | 用途 |
| --------------------------- | -------------- |
| `index.tsx` | 页面 UI |
| `useStorageCleaner.ts` | 业务逻辑 Hook |
| `StorageOptionsGrid.tsx` | 清理选项网格 |
| `StorageCleanerConfirm.tsx` | 清理确认对话框 |
| `AutoRefreshToggle.tsx` | 自动刷新开关 |
| `ErrorDisplay.tsx` | 错误展示组件 |
| `CleaningResult.tsx` | 清理结果展示 |
| `OptionItem.tsx` | 单个选项组件 |
### QrCode/
二维码工具,支持生成(URL→QR)和解析(QR→URL)。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### TextStatistics/
文本统计工具,实时计算字符数、单词数、行数、字节大小。
| 文件 | 用途 |
| ----------- | --------------------------------------------- |
| `index.tsx` | 页面组件,集成 `TextInputArea` 和统计结果展示 |
### Jwt/
JWT 解析工具,解码 Header/Payload/Signature。
| 文件/目录 | 用途 |
| ------------- | ---------------- |
| `index.tsx` | 页面 UI |
| `types.ts` | 类型定义 |
| `contexts/` | Context Provider |
| `hooks/` | 业务逻辑 Hooks |
| `components/` | 子组件 |
### JsonTools/
JSON 工具集:差异比较、格式化、YAML/TOML/Minify 转换。
| 文件 | 用途 |
| ------------------------ | ---------------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `types.ts` | 类型定义 |
| `diffEngine.ts` | 差异比较引擎 |
| `JsonDiffInput.tsx` | JSON 输入组件 |
| `DiffResult.tsx` | 差异结果展示 |
| `DiffNavigator.tsx` | 差异导航器 |
| `JsonFormatSection.tsx` | 格式化区域 |
| `JsonConvertSection.tsx` | 转换区域(YAML/TOML/Minify |
| `JsonTree.tsx` | JSON 树形展示 |
### Base64Converter/
Base64 编解码工具,支持文本/文件/图片三种模式。
| 文件 | 用途 |
| ---------------------------- | --------------------- |
| `index.tsx` | 页面入口,子模式切换 |
| `useBase64Converter.ts` | 业务逻辑 Hook |
| `TextMode.tsx` | 文本模式 |
| `Base64ConverterSection.tsx` | 文件/图片通用转换区域 |
### MarkdownToHtml/
Markdown 转 HTML 工具,支持分栏/预览/源码三种视图模式。
### HtmlToMarkdown/
HTML 转 Markdown 工具,支持分栏/预览/Markdown 三种视图模式。
### RightClickRestorer/
右键菜单恢复工具,解除网站对右键的限制。
| 文件 | 用途 |
| -------------------------- | ------------- |
| `index.tsx` | 页面 UI |
| `useRightClickRestorer.ts` | 业务逻辑 Hook |
## 新增页面
1.`types/storage.d.ts` 添加 `PageType` 联合类型成员
2.`config/features.tsx``FEATURES` 数组添加配置
3.`pages/` 创建页面目录(遵循上述结构)
4.`i18n/locales/{zh,en}/features.json` 添加翻译
5. 如需新权限,更新 `wxt.config.ts`
6. 添加对应的单元测试
+1 -5
View File
@@ -6,10 +6,6 @@ export type RestorerStatus = 'unsupported' | 'locked' | 'unlocked';
export const CARD_CLASS =
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden space-y-3';
export const LOADING_TEXT = '正在加载...';
export const DOMAIN_LABEL = '当前域名';
interface StatusConfig {
badgeVariant: 'default' | 'secondary' | 'destructive';
badgeClassName: string;
@@ -50,7 +46,7 @@ export const STATUS_CONFIG: Record<RestorerStatus, StatusConfig> = {
badgeClassName: 'gap-1 bg-green-600 hover:bg-green-700 shrink-0',
badgeIcon: ShieldCheck,
badgeLabel: '已解锁',
description: '点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。',
description: '当前网站右键菜单已临时解锁。刷新页面后需要重新解锁。',
buttonIcon: MousePointerClick,
buttonLabel: '右键已解锁',
buttonDisabled: true,
+5 -5
View File
@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import { CARD_CLASS, DOMAIN_LABEL, LOADING_TEXT, STATUS_CONFIG } from './constants';
import { CARD_CLASS, STATUS_CONFIG } from './constants';
import { useRightClickRestorer } from './useRightClickRestorer';
export default function Index() {
@@ -13,7 +13,7 @@ export default function Index() {
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80 animate-spin" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{LOADING_TEXT}
...
</span>
</div>
);
@@ -24,9 +24,9 @@ export default function Index() {
const ButtonIcon = config.buttonIcon;
return (
<div className="p-4 w-full flex flex-col space-y-4">
<div className="p-4 w-full">
<div className={CARD_CLASS}>
<Label className="text-sm font-medium">{DOMAIN_LABEL}</Label>
<Label className="text-sm font-medium"></Label>
<div className="flex items-center justify-between gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'}
@@ -42,7 +42,7 @@ export default function Index() {
className="w-full gap-2"
disabled={config.buttonDisabled}
variant={config.buttonVariant}
onClick={config.buttonDisabled ? undefined : () => void unlock()}
onClick={() => void unlock()}
>
<ButtonIcon className="h-4 w-4" />
{config.buttonLabel}
@@ -3,11 +3,6 @@ import { MessageAction, sendMessageToContent } from '@/utils/messages';
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
import type { RestorerStatus } from './constants';
function deriveStatus(isUnsupported: boolean, isUnlocked: boolean): RestorerStatus {
if (isUnsupported) return 'unsupported';
return isUnlocked ? 'unlocked' : 'locked';
}
export interface UseRightClickRestorerReturn {
domain: string;
isLoading: boolean;
@@ -16,10 +11,9 @@ export interface UseRightClickRestorerReturn {
}
export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
const [isUnsupported, setIsUnsupported] = useState<boolean>(false);
const [domain, setDomain] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<RestorerStatus>('locked');
useEffect(() => {
const load = async () => {
@@ -30,13 +24,13 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
setDomain(url ? new URL(url).hostname : '');
if (isUnsupportedPageUrl(url)) {
setIsUnsupported(true);
setStatus('unsupported');
return;
}
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
if (response?.success) {
setIsUnlocked(response.restored);
setStatus(response.restored ? 'unlocked' : 'locked');
}
} catch (err) {
console.error('[RightClickRestorer] Failed to load state:', err);
@@ -49,12 +43,12 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
}, []);
async function unlock() {
if (isUnsupported) return;
if (status === 'unsupported') return;
try {
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
if (response?.success) {
setIsUnlocked(response.restored);
setStatus(response.restored ? 'unlocked' : 'locked');
}
} catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err);
@@ -64,7 +58,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
return {
domain,
isLoading,
status: deriveStatus(isUnsupported, isUnlocked),
status,
unlock,
};
}
@@ -5,7 +5,6 @@ import Index from '../index';
import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner';
import { toast } from 'sonner';
// Mock the chrome APIs
vi.mock('@/utils/chromeStorage', () => ({
storageUtil: {
get: vi.fn().mockResolvedValue(null),
@@ -103,4 +102,31 @@ describe('StorageCleaner 页面', () => {
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
});
});
it('展示的数据与当前标签页不一致时不应执行清理', async () => {
let currentTabId = 1;
vi.mocked(getCurrentTab).mockImplementation(
async () =>
({
id: currentTabId,
url: currentTabId === 1 ? 'https://a.example.com' : 'https://b.example.com',
}) as any,
);
render(<Index />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
});
currentTabId = 2;
fireEvent.click(screen.getByRole('button', { name: /立即清理/ }));
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
await waitFor(() => {
expect(clearStorage).not.toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith('当前标签页已切换,请等待数据刷新后再清理');
});
});
});
@@ -1,18 +1,3 @@
/**
* 自动刷新开关行
*
* 用于 StorageCleaner 页面的操作区域内(非独立卡片),
* 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。
*
* @example
* ```tsx
* <AutoRefreshToggle
* reloadAfterClean={reloadAfterClean}
* onChange={(checked) => setReloadAfterClean(checked)}
* />
* ```
*/
import React from 'react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch';
@@ -22,20 +7,10 @@ export interface AutoRefreshToggleProps extends Omit<
React.HTMLAttributes<HTMLDivElement>,
'onChange'
> {
/** 开关的当前状态(受控) */
reloadAfterClean: boolean;
/** 状态变化时的回调函数 */
onChange: (checked: boolean) => void;
}
/**
* 自动刷新开关
*
* @param reloadAfterClean - 当前开关状态
* @param onChange - 状态变化回调,接收新的布尔值
* @param className - 额外的 CSS 类名,用于覆盖或扩展样式
* @param props - 透传给外层 div 的其他 HTML 属性
*/
export default function AutoRefreshToggle({
reloadAfterClean,
onChange,
@@ -54,7 +29,7 @@ export default function AutoRefreshToggle({
htmlFor="auto-refresh-switch"
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
>
{'清理后自动刷新页面'}
</Label>
<Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} />
@@ -24,7 +24,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
</p>
<p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2">
{'存储清理功能仅适用于标准网页'}
</p>
</div>
</div>
@@ -2,11 +2,11 @@ import React from 'react';
import { formatBytes } from '@/utils/format';
import { cn } from '@/lib/utils';
import type { StorageSizeInfo } from '../useStorageCleaner';
import { OPTION_LABELS } from '../constants';
import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '../constants';
import { Checkbox } from '@/components/ui/checkbox';
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
labelKey: string;
labelKey: (typeof CLEAN_OPTION_KEYS)[number];
checked: boolean;
sizeInfo?: StorageSizeInfo;
onChange: () => void;
@@ -22,8 +22,7 @@ export default function OptionItem({
}: OptionItemProps) {
const sizeValue = sizeInfo?.value;
const isCount = sizeInfo?.displayType === 'count';
const label =
labelKey in OPTION_LABELS ? OPTION_LABELS[labelKey as keyof typeof OPTION_LABELS] : labelKey;
const label = OPTION_LABELS[labelKey];
return (
<div
@@ -58,7 +57,7 @@ export default function OptionItem({
</span>
) : (
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
{'无数据'}
</span>
)}
</div>
@@ -10,7 +10,7 @@ import {
import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react';
import type { StorageCleanerOptions } from '@/types/storage';
import { OPTION_LABELS } from '../constants';
import { OPTION_LABELS, CLEAN_OPTION_KEYS } from '../constants';
import { cn } from '@/lib/utils';
export interface StorageCleanerConfirmProps {
@@ -26,11 +26,9 @@ export function StorageCleanerConfirm({
onConfirm,
options,
}: StorageCleanerConfirmProps) {
const selectedOptions = Object.entries(options)
.filter(([_, value]) => value)
.map(([key]) =>
key in OPTION_LABELS ? OPTION_LABELS[key as keyof typeof OPTION_LABELS] : key,
);
const selectedOptions = CLEAN_OPTION_KEYS.filter((key) => options[key]).map(
(key) => OPTION_LABELS[key],
);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -42,14 +40,14 @@ export function StorageCleanerConfirm({
{/* 头部标题区域 */}
<DialogHeader className="pt-1">
<DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground">
{'确认清理数据?'}
</DialogTitle>
</DialogHeader>
{/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */}
<div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto">
<DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed">
{'您将永久删除当前页面的以下选定存储项。'}
</DialogDescription>
{/* 待清理项目徽章群 */}
@@ -69,7 +67,7 @@ export function StorageCleanerConfirm({
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
{'此操作不可撤销'}
</span>
</div>
</div>
@@ -81,7 +79,7 @@ export function StorageCleanerConfirm({
onClick={onConfirm}
className="w-full text-xs font-bold shadow-sm h-9"
>
{'确认清理'}
</Button>
<Button
variant="outline"
@@ -89,7 +87,7 @@ export function StorageCleanerConfirm({
onClick={onClose}
className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground"
>
{'取消'}
</Button>
</DialogFooter>
</DialogContent>
@@ -51,7 +51,7 @@ export default function StorageOptionsGrid({
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
>
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
{'全选所有项'}
</Label>
<Checkbox
+4 -3
View File
@@ -15,6 +15,7 @@ export default function Index() {
sizes,
reloadAfterClean,
loading,
isRefreshingSizes,
result,
showConfirm,
setShowConfirm,
@@ -26,14 +27,14 @@ export default function Index() {
handleClean,
} = useStorageCleaner();
const isButtonDisabled = !(someSelected || allSelected) || loading;
const isButtonDisabled = !(someSelected || allSelected) || loading || isRefreshingSizes;
if (isInitializing) {
return (
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
{'正在读取数据...'}
...
</span>
</div>
);
@@ -72,7 +73,7 @@ export default function Index() {
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{'正在清理...'}
...
</>
) : (
'立即清理'
+19 -11
View File
@@ -75,6 +75,7 @@ export interface UseStorageCleanerReturn {
sizes: Record<string, StorageSizeInfo>;
reloadAfterClean: boolean;
loading: boolean;
isRefreshingSizes: boolean;
result: CleaningResult | null;
showConfirm: boolean;
setShowConfirm: (show: boolean) => void;
@@ -95,10 +96,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [isRefreshingSizes, setIsRefreshingSizes] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false);
const requestIdRef = useRef<number>(0);
const boundTabRef = useRef<{ id: number; url: string } | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const storageTimerRef = useRef<NodeJS.Timeout | null>(null);
const loadingRef = useRef(loading);
@@ -114,9 +117,9 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
};
}, []);
// 核心数据拉取链条
const loadInfo = useCallback(async () => {
const currentRequestId = ++requestIdRef.current;
setIsRefreshingSizes(true);
try {
const tab = await getCurrentTab();
if (currentRequestId !== requestIdRef.current) return;
@@ -159,6 +162,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
cacheStorage: { value: cacheCount, displayType: 'count' },
serviceWorkers: { value: swCount, displayType: 'count' },
});
boundTabRef.current = { id: tabId, url };
} catch (err) {
console.error('Failed to load storage cleaner info:', err);
if (currentRequestId === requestIdRef.current) {
@@ -167,6 +171,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
} finally {
if (currentRequestId === requestIdRef.current) {
setIsInitializing(false);
setIsRefreshingSizes(false);
}
}
}, []);
@@ -183,7 +188,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}, 300);
}, []);
// 监听浏览器标签行为
useEffect(() => {
loadInfoRef.current().catch(console.error);
@@ -210,15 +214,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
storageTimerRef.current = setTimeout(async () => {
try {
await storageUtil.set('storageCleaner/preferences', {
await storageUtil
.set('storageCleaner/preferences', {
reloadAfterClean,
selectedTypes: options,
});
} catch (err) {
console.error('Failed to save storage cleaner preferences:', err);
toast.warning('偏好保存失败,本次设置可能不会保留');
}
})
.catch(console.error);
}, 500);
}, [options, reloadAfterClean, isInitializing]);
@@ -245,7 +246,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (loadingRef.current) return;
const tab = await getCurrentTab();
if (!tab || tab.id === undefined || !tab.url) {
if (!tab || !tab.id || !tab.url) {
toast.warning('无法获取当前标签页');
return;
}
@@ -256,6 +257,13 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
return;
}
const boundTab = boundTabRef.current;
if (!boundTab || boundTab.id !== tab.id) {
toast.warning('当前标签页已切换,请等待数据刷新后再清理');
setShowConfirm(false);
return;
}
setLoading(true);
setShowConfirm(false);
try {
@@ -273,7 +281,6 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
toast.error(`清理失败: ${String(err)}`);
} finally {
setLoading(false);
setShowConfirm(false);
}
}, [options, reloadAfterClean, loadInfo]);
@@ -297,6 +304,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
sizes,
reloadAfterClean,
loading,
isRefreshingSizes,
result,
showConfirm,
setShowConfirm,
@@ -75,7 +75,7 @@ export default function DataPreview({ fields }: DataPreviewProps) {
return (
<div className="flex flex-col items-center justify-center h-full text-center">
<FileJson className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-xs text-muted-foreground/50">{'配置字段后点击「生成数据」按钮'}</p>
<p className="text-xs text-muted-foreground/50"></p>
</div>
);
}
@@ -87,10 +87,10 @@ export default function DataPreview({ fields }: DataPreviewProps) {
<div className="h-5 w-5 rounded bg-primary/10 flex items-center justify-center">
<FileText className="h-3 w-3 text-primary" />
</div>
<span className="text-xs font-medium text-muted-foreground">{'示例数据'}</span>
<span className="text-xs font-medium text-muted-foreground"></span>
</div>
<span className="text-[10px] text-muted-foreground/50 bg-muted px-1.5 py-0.5 rounded">
{Object.keys(sampleData).length} {'字段'}
{Object.keys(sampleData).length}
</span>
</div>
@@ -50,27 +50,27 @@ export default function ExportPanel({ result }: ExportPanelProps) {
return (
<div className="space-y-2">
<h4 className="text-sm font-medium text-foreground">{'导出数据'}</h4>
<h4 className="text-sm font-medium text-foreground"></h4>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm" onClick={handleCopyJSON} className="h-9 gap-1.5">
<Copy className="h-4 w-4" />
{'复制 JSON'}
JSON
</Button>
<Button variant="outline" size="sm" onClick={handleCopyCSV} className="h-9 gap-1.5">
<Copy className="h-4 w-4" />
{'复制 CSV'}
CSV
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadJSON} className="h-9 gap-1.5">
<Download className="h-4 w-4" />
{'下载 JSON'}
JSON
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadCSV} className="h-9 gap-1.5">
<Download className="h-4 w-4" />
{'下载 CSV'}
CSV
</Button>
</div>
</div>
@@ -102,13 +102,13 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'字段名称'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<span className="text-xs text-muted-foreground">{field.name.length}/20</span>
</div>
<Input
value={field.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder={'请输入字段名称'}
placeholder="请输入字段名称"
maxLength={20}
className={`h-9 ${nameError ? 'border-destructive' : ''}`}
/>
@@ -117,7 +117,7 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'字段描述'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<span className="text-xs text-muted-foreground">
{(field.description || '').length}/50
</span>
@@ -125,7 +125,7 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<Input
value={field.description || ''}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder={'可选,添加字段说明'}
placeholder="可选,添加字段说明"
maxLength={50}
className="h-9"
/>
@@ -134,14 +134,14 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'必填'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<Switch checked={field.required} onCheckedChange={handleRequiredChange} />
</div>
{!field.required && (
<div className="space-y-2 pl-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">{'空值率'}</span>
<span className="text-sm text-muted-foreground"></span>
<Badge variant="secondary" className="text-xs">
{field.nullRate}%
</Badge>
@@ -180,18 +180,18 @@ export default function FieldEditor({ field, onChange, allFieldNames = [] }: Fie
</div>
<div className="flex items-center justify-between">
<Label className="text-sm font-medium text-foreground">{'唯一性约束'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<Switch checked={field.unique} onCheckedChange={handleUniqueChange} />
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'数据生成器'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<GeneratorSelector selectedId={field.generatorId} onChange={handleGeneratorChange} />
</div>
{generator && (
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'生成器参数'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<GeneratorConfig
generator={generator}
params={field.params}
@@ -23,7 +23,6 @@ export default function FieldItem({ field, onClick }: FieldItemProps) {
<div
className={`h-full ${field.description ? 'flex flex-col justify-center' : 'flex items-center'}`}
>
{/* 第一行:字段名 + 标签 */}
<div className="flex items-center gap-1.5 overflow-hidden">
<span className="font-medium text-sm text-foreground truncate shrink-0 max-w-[140px]">
{field.name}
@@ -41,11 +40,10 @@ export default function FieldItem({ field, onClick }: FieldItemProps) {
)}
{field.unique && (
<Badge variant="outline" className="text-[10px] shrink-0 px-1 py-0 text-blue-500">
{'唯一'}
</Badge>
)}
</div>
{/* 第二行:描述 */}
{field.description && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate leading-tight">
{field.description}
@@ -86,7 +86,6 @@ function SortableFieldItem({
}`}
>
<div className="flex items-center gap-2 h-full px-3 py-2">
{/* 拖拽手柄 */}
<button
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-muted/50 touch-none shrink-0"
{...attributes}
@@ -267,7 +266,7 @@ export default function FieldList({
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground">
{'字段'} ({fields.length}/{MAX_FIELDS})
({fields.length}/{MAX_FIELDS})
</h3>
<div className="flex items-center gap-2">
{editingRule ? (
@@ -278,10 +277,10 @@ export default function FieldList({
onClick={handleUpdateRule}
disabled={fields.length === 0}
className="h-8 gap-1.5 px-2.5"
title={`${'编辑中'}: ${editingRule.name}`}
title={`编辑中: ${editingRule.name}`}
>
<Save className="h-3.5 w-3.5" />
{'更新规则'}
</Button>
<Button
variant="ghost"
@@ -290,7 +289,7 @@ export default function FieldList({
disabled={fields.length === 0}
className="h-8 px-2"
>
{'另存为'}
</Button>
</>
) : (
@@ -302,7 +301,7 @@ export default function FieldList({
className="h-8 gap-1.5 px-2.5"
>
<Save className="h-3.5 w-3.5" />
{'保存规则'}
</Button>
)}
<Button
@@ -313,7 +312,7 @@ export default function FieldList({
className="h-8 gap-1.5 px-2.5"
>
<Plus className="h-4 w-4" />
{'添加字段'}
</Button>
</div>
</div>
@@ -322,8 +321,8 @@ export default function FieldList({
{fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<GripVertical className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{'暂无字段'}</p>
<p className="text-xs text-muted-foreground/70 mt-1">{'点击上方按钮添加第一个字段'}</p>
<p className="text-sm text-muted-foreground"></p>
<p className="text-xs text-muted-foreground/70 mt-1"></p>
</div>
) : (
<DndContext
@@ -405,7 +404,6 @@ export default function FieldList({
)}
</div>
{/* 保存规则对话框 */}
<Dialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<DialogContent
showCloseButton={false}
@@ -415,42 +413,41 @@ export default function FieldList({
<Input
value={ruleName}
onChange={(e) => setRuleName(e.target.value)}
placeholder={'规则名称'}
placeholder="规则名称"
className="h-9"
/>
<Input
value={ruleDescription}
onChange={(e) => setRuleDescription(e.target.value)}
placeholder={'规则描述(可选)'}
placeholder="规则描述(可选)"
className="h-9"
/>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowSaveDialog(false)}>
{'取消'}
</Button>
<Button size="sm" onClick={() => handleSave()} disabled={!ruleName.trim()}>
{'确认'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 覆盖确认对话框 */}
<Dialog open={showConfirmOverwrite} onOpenChange={setShowConfirmOverwrite}>
<DialogContent
showCloseButton={false}
className="w-[calc(100vw-4rem)] max-w-[420px] p-0 pt-6 flex flex-col"
>
<div className="flex-1 overflow-y-auto px-6 pb-4">
<p className="text-sm text-muted-foreground">{'已存在同名规则,是否覆盖保存?'}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setShowConfirmOverwrite(false)}>
{'取消'}
</Button>
<Button size="sm" onClick={() => handleSave(true)}>
{'覆盖'}
</Button>
</div>
</DialogContent>
@@ -1,6 +1,5 @@
/**
* 生成按钮组件
* 显示生成按钮、加载状态和进度条
*/
import { Play, Square } from 'lucide-react';
@@ -28,14 +27,13 @@ export default function GenerateButton({
<>
<Button variant="destructive" onClick={onCancel} className="w-full h-11 gap-2">
<Square className="h-5 w-5" />
{'取消'}
</Button>
{/* 进度条 */}
{progress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{`已生成 ${progress.generated} / ${progress.total}`}</span>
<span>{`已生成 ${progress.generated.toLocaleString()} / ${progress.total.toLocaleString()}`}</span>
<span>{progress.progress}%</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
@@ -46,7 +44,7 @@ export default function GenerateButton({
</div>
{progress.estimatedTimeLeft !== undefined && (
<p className="text-xs text-muted-foreground text-center">
{`预计剩余 ${progress.estimatedTimeLeft}`}
{`预计剩余 ${Math.ceil(progress.estimatedTimeLeft / 1000)}`}
</p>
)}
</div>
@@ -55,7 +53,7 @@ export default function GenerateButton({
) : (
<Button onClick={onClick} disabled={disabled} className="w-full h-11 gap-2">
<Play className="h-5 w-5" />
{'生成数据'}
</Button>
)}
</div>
@@ -28,9 +28,8 @@ export default function GenerateOptions({
}: GenerateOptionsProps) {
return (
<div className="space-y-4">
{/* 生成数量 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'生成数量'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<div className="flex flex-wrap gap-2">
{COUNT_PRESETS.map((preset) => (
<button
@@ -61,9 +60,8 @@ export default function GenerateOptions({
/>
</div>
{/* 数据格式 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-foreground">{'数据格式'}</Label>
<Label className="text-sm font-medium text-foreground"></Label>
<div className="flex gap-2">
{FORMAT_OPTIONS.map((option) => (
<button
@@ -27,7 +27,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
};
if (generator.params.length === 0) {
return <p className="text-sm text-muted-foreground py-2">{'此生成器无可配置参数'}</p>;
return <p className="text-sm text-muted-foreground py-2"></p>;
}
return (
@@ -108,7 +108,7 @@ export default function GeneratorConfig({ generator, params, onChange }: Generat
.filter(Boolean),
)
}
placeholder={'用逗号分隔多个值'}
placeholder="用逗号分隔多个值"
className="h-9"
/>
)}
@@ -30,18 +30,16 @@ export default function GeneratorSelector({ selectedId, onChange }: GeneratorSel
return (
<div className="space-y-3">
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={'搜索生成器...'}
placeholder="搜索生成器..."
className="pl-9 h-9"
/>
</div>
{/* 分类标签 */}
{!searchQuery && (
<div className="flex flex-wrap gap-2">
{generatorCategories.map((category) => {
@@ -64,7 +62,6 @@ export default function GeneratorSelector({ selectedId, onChange }: GeneratorSel
</div>
)}
{/* 生成器列表 */}
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto">
{generators.map((generator) => (
<button
@@ -35,13 +35,11 @@ export default function ResultPanel({ result }: ResultPanelProps) {
return (
<div className="space-y-3">
{/* 状态标题 */}
<div className="flex items-center gap-2">
{getStatusIcon()}
<span className="text-sm font-medium text-foreground">{getStatusText()}</span>
</div>
{/* 统计信息 */}
{result.stats && (
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col items-center p-2 rounded-lg bg-muted/30">
@@ -49,7 +47,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<span className="text-lg font-semibold text-foreground">
{result.stats.total.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">{'总条数'}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex flex-col items-center p-2 rounded-lg bg-muted/30">
@@ -57,7 +55,7 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<span className="text-lg font-semibold text-green-500">
{result.stats.success.toLocaleString()}
</span>
<span className="text-xs text-muted-foreground">{'成功'}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
<div className="flex flex-col items-center p-2 rounded-lg bg-muted/30">
@@ -65,17 +63,16 @@ export default function ResultPanel({ result }: ResultPanelProps) {
<span className="text-lg font-semibold text-foreground">
{(result.stats.duration / 1000).toFixed(2)}s
</span>
<span className="text-xs text-muted-foreground">{'耗时'}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
)}
{/* 警告信息 */}
{result.warnings && result.warnings.length > 0 && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-500" />
<span className="text-sm font-medium text-yellow-500">{'警告'}</span>
<span className="text-sm font-medium text-yellow-500"></span>
</div>
<ul className="list-disc list-inside space-y-1">
{result.warnings.slice(0, 5).map((warning, index) => (
@@ -92,7 +89,6 @@ export default function ResultPanel({ result }: ResultPanelProps) {
</div>
)}
{/* 错误信息 */}
{result.error && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<div className="flex items-center gap-2">
@@ -165,7 +165,6 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
return (
<div className="space-y-4">
{/* 删除确认对话框 */}
<Dialog open={!!ruleToDelete} onOpenChange={() => setRuleToDelete(null)}>
<DialogContent
showCloseButton={false}
@@ -178,16 +177,15 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setRuleToDelete(null)}>
{'取消'}
</Button>
<Button variant="destructive" size="sm" onClick={handleDelete}>
{'确认'}
</Button>
</div>
</DialogContent>
</Dialog>
{/* 工具栏 */}
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
@@ -201,7 +199,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
) : (
<Upload className="h-4 w-4" />
)}
{'导入'}
</Button>
<Button
variant="outline"
@@ -215,17 +213,16 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
) : (
<Download className="h-4 w-4" />
)}
{'导出数据'}
</Button>
</div>
{/* 搜索框 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value.slice(0, 20))}
placeholder={'搜索规则...'}
placeholder="搜索规则..."
className="pl-9 pr-24 h-9"
maxLength={20}
/>
@@ -234,7 +231,6 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
</span>
</div>
{/* 规则列表 */}
<div className="space-y-2 max-h-64 overflow-y-auto">
{filteredRules.length === 0 ? (
<div className="text-center py-6">
@@ -252,9 +248,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground truncate">{rule.name}</span>
<span className="text-xs text-muted-foreground">
{rule.fields.length} {'字段'}
</span>
<span className="text-xs text-muted-foreground">{rule.fields.length} </span>
</div>
{rule.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">
@@ -276,7 +270,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon"
className="h-7 w-7"
onClick={() => handleLoad(rule)}
title={'加载'}
title="加载"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
@@ -285,7 +279,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon"
className="h-7 w-7"
onClick={() => handleEdit(rule)}
title={'编辑'}
title="编辑"
>
<Edit className="h-3.5 w-3.5" />
</Button>
@@ -294,7 +288,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon"
className="h-7 w-7"
onClick={() => handleDuplicate(rule.id)}
title={'复制'}
title="复制"
>
<Copy className="h-3.5 w-3.5" />
</Button>
@@ -303,7 +297,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => setRuleToDelete(rule)}
title={'删除'}
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -0,0 +1,138 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useGenerator } from '@/pages/TestDataGenerator/hooks/useGenerator';
import type { FieldConfig, WorkerResponseMessage } from '@/types/testDataGenerator';
const mockField: FieldConfig = {
id: 'field-1',
name: 'username',
generatorId: 'string',
params: {},
required: true,
nullRate: 0,
unique: false,
};
type WorkerListener = (event: MessageEvent<WorkerResponseMessage>) => void;
class MockWorker {
static instances: MockWorker[] = [];
onmessage: WorkerListener | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
postedMessages: unknown[] = [];
constructor(_url: URL, _options?: WorkerOptions) {
MockWorker.instances.push(this);
}
postMessage(message: unknown) {
this.postedMessages.push(message);
}
terminate() {
const index = MockWorker.instances.indexOf(this);
if (index >= 0) {
MockWorker.instances.splice(index, 1);
}
}
emit(message: WorkerResponseMessage) {
this.onmessage?.({ data: message } as MessageEvent<WorkerResponseMessage>);
}
}
describe('useGenerator', () => {
beforeEach(() => {
MockWorker.instances = [];
vi.stubGlobal('Worker', MockWorker);
});
it('应忽略过期 generationId 的 complete 消息', async () => {
const { result } = renderHook(() => useGenerator());
act(() => {
result.current.generate([mockField], 10);
});
const worker = MockWorker.instances[0];
expect(worker).toBeDefined();
act(() => {
result.current.cancel();
});
act(() => {
result.current.generate([mockField], 5);
});
act(() => {
worker.emit({
type: 'complete',
generationId: 1,
payload: {
success: true,
data: [{ username: 'stale' }],
stats: { total: 10, success: 10, failed: 0, duration: 1 },
},
});
});
expect(result.current.result).toBeNull();
expect(result.current.isGenerating).toBe(true);
act(() => {
worker.emit({
type: 'complete',
generationId: 3,
payload: {
success: true,
data: [{ username: 'fresh' }],
stats: { total: 5, success: 5, failed: 0, duration: 1 },
},
});
});
await waitFor(() => {
expect(result.current.isGenerating).toBe(false);
});
expect(result.current.result?.data?.[0]).toEqual({ username: 'fresh' });
});
it('cancel 后应发送 cancel 消息并使 generationId 失效', () => {
const { result } = renderHook(() => useGenerator());
act(() => {
result.current.generate([mockField], 100);
});
const worker = MockWorker.instances[0];
act(() => {
result.current.cancel();
});
expect(result.current.isGenerating).toBe(false);
expect(worker.postedMessages).toEqual(expect.arrayContaining([{ type: 'cancel' }]));
});
it('正在生成时不应重复发送 start 消息', () => {
const { result } = renderHook(() => useGenerator());
act(() => {
result.current.generate([mockField], 10);
result.current.generate([mockField], 20);
});
const worker = MockWorker.instances[0];
const startMessages = worker.postedMessages.filter(
(message): message is { type: 'start'; payload: { count: number } } =>
typeof message === 'object' &&
message !== null &&
'type' in message &&
message.type === 'start',
);
expect(startMessages).toHaveLength(1);
expect(startMessages[0]?.payload.count).toBe(10);
});
});
@@ -8,7 +8,8 @@ import type {
FieldConfig,
GenerateResult,
GenerateProgress,
WorkerMessage,
WorkerRequestMessage,
WorkerResponseMessage,
} from '@/types/testDataGenerator';
export interface UseGeneratorReturn {
@@ -35,6 +36,13 @@ export function useGenerator(): UseGeneratorReturn {
const [error, setError] = useState<string | null>(null);
const workerRef = useRef<Worker | null>(null);
const generationIdRef = useRef(0);
const isGeneratingRef = useRef(false);
const finishGenerating = useCallback(() => {
isGeneratingRef.current = false;
setIsGenerating(false);
}, []);
// 清理 Worker
useEffect(() => {
@@ -58,22 +66,30 @@ export function useGenerator(): UseGeneratorReturn {
type: 'module',
});
worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
worker.onmessage = (e: MessageEvent<WorkerResponseMessage>) => {
const data = e.data;
const { type } = data;
if (data.generationId !== generationIdRef.current) {
return;
}
switch (type) {
case 'progress':
setProgress(data.payload as GenerateProgress);
setProgress(data.payload);
break;
case 'complete':
setIsGenerating(false);
setResult(data.payload as GenerateResult);
finishGenerating();
if (data.payload.success) {
setResult(data.payload);
} else if (data.payload.error && data.payload.error !== '生成已取消') {
setError(data.payload.error);
}
setProgress(null);
break;
case 'error':
setIsGenerating(false);
setError((data.payload as { error: string }).error);
finishGenerating();
setError(data.payload.error);
setProgress(null);
break;
}
@@ -81,7 +97,7 @@ export function useGenerator(): UseGeneratorReturn {
worker.onerror = (err) => {
console.error('[useGenerator] Worker 错误:', err);
setIsGenerating(false);
finishGenerating();
setError(err.message || 'Worker 运行错误');
setProgress(null);
// Worker 出错后销毁,下次重新创建
@@ -91,14 +107,17 @@ export function useGenerator(): UseGeneratorReturn {
workerRef.current = worker;
return worker;
}, []);
}, [finishGenerating]);
/**
* 开始生成
*/
const generate = useCallback(
(fields: FieldConfig[], count: number, csvMode = false) => {
if (isGenerating) return;
if (isGeneratingRef.current) return;
const generationId = ++generationIdRef.current;
isGeneratingRef.current = true;
setIsGenerating(true);
setProgress(null);
@@ -106,26 +125,28 @@ export function useGenerator(): UseGeneratorReturn {
setError(null);
const worker = getWorker();
const message: WorkerMessage = {
const message: WorkerRequestMessage = {
type: 'start',
payload: { fields, count, csvMode },
payload: { generationId, fields, count, csvMode },
};
worker.postMessage(message);
},
[isGenerating, getWorker],
[getWorker],
);
/**
* 取消生成
*/
const cancel = useCallback(() => {
if (workerRef.current && isGenerating) {
const message: WorkerMessage = { type: 'cancel' };
if (workerRef.current && isGeneratingRef.current) {
++generationIdRef.current;
const message: WorkerRequestMessage = { type: 'cancel' };
workerRef.current.postMessage(message);
isGeneratingRef.current = false;
setIsGenerating(false);
setProgress(null);
}
}, [isGenerating]);
}, []);
/**
* 清除结果
+12 -40
View File
@@ -1,21 +1,10 @@
/**
* 测试数据生成器主页面
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Settings, Database, Tag } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useGenerator } from './hooks/useGenerator';
import type { FieldConfig, GenerateResult, DataRule } from '@/types/testDataGenerator';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
// 生成唯一 ID 的辅助函数
function generateId(): string {
return `field_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
import { useGenerator } from './hooks/useGenerator';
import FieldList, { MAX_FIELDS } from './components/FieldList';
import FieldEditor from './components/FieldEditor';
import GenerateOptions from './components/GenerateOptions';
@@ -24,39 +13,38 @@ import DataPreview from './components/DataPreview';
import ResultPanel from './components/ResultPanel';
import ExportPanel from './components/ExportPanel';
import RuleManager from './components/RuleManager';
import type { FieldConfig, GenerateResult, DataRule } from '@/types/testDataGenerator';
function generateId(): string {
return `field_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
type TabType = 'fields' | 'rules';
export default function TestDataGeneratorPage() {
const { isGenerating, progress, result, error, generate, cancel, clearResult } = useGenerator();
// 字段配置
const [fields, setFields] = useState<FieldConfig[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [editingRule, setEditingRule] = useState<DataRule | null>(null);
// 生成选项
const [count, setCount] = useState(100);
const [format, setFormat] = useState<'json' | 'csv'>('json');
// 当前标签页
const [activeTab, setActiveTab] = useState<TabType>('fields');
// 生成完成时显示 toast 提示(避免重复触发)
const lastToastResultRef = useRef<GenerateResult | null>(null);
// 生成完成时显示 toast 提示(避免重复触发)
useEffect(() => {
if (result?.success && result.stats && result !== lastToastResultRef.current) {
lastToastResultRef.current = result;
toast.success('生成完成', {
description: `${result.stats.total} ${'条数据'}`,
description: `${result.stats.total} 条数据`,
});
}
}, [result]);
// 添加新字段
const handleAddField = useCallback(() => {
setFields((prev) => {
if (prev.length >= MAX_FIELDS) return prev;
@@ -74,7 +62,6 @@ export default function TestDataGeneratorPage() {
});
}, []);
// 更新字段
const handleUpdateField = useCallback((index: number, field: FieldConfig) => {
setFields((prev) => {
const newFields = [...prev];
@@ -83,7 +70,6 @@ export default function TestDataGeneratorPage() {
});
}, []);
// 删除字段
const handleRemoveField = useCallback((index: number) => {
setFields((prev) => prev.filter((_, i) => i !== index));
setSelectedIndex((prev) => {
@@ -93,7 +79,6 @@ export default function TestDataGeneratorPage() {
});
}, []);
// 拖拽排序
const handleReorder = useCallback((oldIndex: number, newIndex: number) => {
setFields((prev) => {
const newFields = [...prev];
@@ -111,7 +96,6 @@ export default function TestDataGeneratorPage() {
});
}, []);
// 加载规则
const handleLoadRule = useCallback(
(loadedFields: FieldConfig[]) => {
setFields(loadedFields);
@@ -122,7 +106,6 @@ export default function TestDataGeneratorPage() {
[clearResult],
);
// 编辑规则
const handleEditRule = useCallback(
(rule: DataRule) => {
setFields(rule.fields);
@@ -140,16 +123,12 @@ export default function TestDataGeneratorPage() {
setEditingRule(null);
}, []);
// 开始生成
const handleGenerate = useCallback(() => {
if (fields.length === 0) return;
generate(fields, count, format === 'csv');
}, [fields, count, format, generate]);
// 获取选中的字段(-1 或 null 表示未选中)
const selectedField = selectedIndex !== null && selectedIndex >= 0 ? fields[selectedIndex] : null;
// 打开字段编辑器
const handleOpenEditor = useCallback((index: number) => {
setSelectedIndex(index);
setIsEditorOpen(true);
@@ -174,7 +153,7 @@ export default function TestDataGeneratorPage() {
)}
>
<Settings className="h-4 w-4" />
{'字段配置'}
</button>
<button
onClick={() => setActiveTab('rules')}
@@ -186,11 +165,10 @@ export default function TestDataGeneratorPage() {
)}
>
<Tag className="h-4 w-4" />
{'规则管理'}
</button>
</div>
{/* 字段配置标签页 */}
{activeTab === 'fields' && (
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<FieldList
@@ -206,14 +184,12 @@ export default function TestDataGeneratorPage() {
</div>
)}
{/* 规则管理标签页 */}
{activeTab === 'rules' && (
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<RuleManager onLoad={handleLoadRule} onEdit={handleEditRule} />
</div>
)}
{/* 生成选项 */}
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<GenerateOptions
count={count}
@@ -223,7 +199,6 @@ export default function TestDataGeneratorPage() {
/>
</div>
{/* 生成按钮 */}
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<GenerateButton
onClick={handleGenerate}
@@ -253,18 +228,16 @@ export default function TestDataGeneratorPage() {
</div>
)}
{/* 数据预览 */}
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<Database className="h-4 w-4" />
{'数据预览'}
</h3>
<div className="h-[280px]">
<DataPreview fields={fields} />
</div>
</div>
{/* 导出面板 */}
{result?.data && result.data.length > 0 && (
<div className="p-4 rounded-xl border border-border bg-card shadow-sm">
<ExportPanel result={result} />
@@ -274,7 +247,6 @@ export default function TestDataGeneratorPage() {
</div>
</div>
{/* 字段编辑弹窗 */}
<Dialog open={isEditorOpen} onOpenChange={setIsEditorOpen}>
<DialogContent
showCloseButton={false}
@@ -291,10 +263,10 @@ export default function TestDataGeneratorPage() {
</div>
<div className="flex justify-end gap-2 px-6 py-2 border-t shrink-0">
<Button variant="ghost" size="sm" onClick={() => setIsEditorOpen(false)}>
{'取消'}
</Button>
<Button size="sm" onClick={() => setIsEditorOpen(false)}>
{'完成'}
</Button>
</div>
</DialogContent>
@@ -1,17 +0,0 @@
interface StatCardProps {
label: string;
value: React.ReactNode;
}
export default function StatCard({ label, value }: StatCardProps) {
return (
<div className="flex flex-col justify-center items-center p-4 text-center rounded-xl border border-border bg-card shadow-sm text-card-foreground hover:-translate-y-0.5 hover:shadow-md hover:border-primary/50 focus-within:ring-1 focus-within:ring-ring">
<span className="text-xs font-medium text-muted-foreground tracking-wider mb-1 select-none">
{label}
</span>
<span className="font-mono text-lg md:text-2xl font-extrabold text-primary break-all tracking-tight leading-none tabular-nums select-all">
{value}
</span>
</div>
);
}
+25 -6
View File
@@ -1,17 +1,24 @@
import TextInputArea from '@/components/TextInputArea';
import { formatBytes } from '@/utils/format';
import StatCard from './components/StatCard';
import { cn } from '@/lib/utils';
import { useTextStatistics } from './useTextStatistics';
export default function Index() {
const { text, stats, setText } = useTextStatistics();
const statItems = [
{ label: '字符数', value: stats.characters },
{ label: '单词数', value: stats.words },
{ label: '行数', value: stats.lines },
{ label: '字节大小', value: formatBytes(stats.bytes) },
];
return (
<div className="p-4 w-full space-y-4">
<TextInputArea
value={text}
onChange={setText}
placeholder={'在此输入或粘贴文本...'}
placeholder="在此输入或粘贴文本..."
minRows={10}
maxRows={18}
showClear={true}
@@ -19,10 +26,22 @@ export default function Index() {
/>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="字符数" value={stats.characters} />
<StatCard label="单词数" value={stats.words} />
<StatCard label="行数" value={stats.lines} />
<StatCard label="字节大小" value={formatBytes(stats.bytes)} />
{statItems.map((item) => (
<div
key={item.label}
className={cn(
'flex flex-col justify-center items-center p-4 text-center rounded-xl border border-border bg-card shadow-sm text-card-foreground',
'hover:-translate-y-0.5 hover:shadow-md hover:border-primary/50 focus-within:ring-1 focus-within:ring-ring',
)}
>
<span className="text-xs font-medium text-muted-foreground tracking-wider mb-1 select-none">
{item.label}
</span>
<span className="font-mono text-lg md:text-2xl font-extrabold text-primary break-all tracking-tight leading-none tabular-nums select-all">
{item.value}
</span>
</div>
))}
</div>
</div>
);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useState } from 'react';
import { getTextStats, type TextStats } from '@/utils/textStatistics';
import { useContextMenuData } from '@/utils/useContextMenuData';
@@ -10,12 +10,6 @@ export interface UseTextStatisticsReturn {
export function useTextStatistics(): UseTextStatisticsReturn {
const [text, setText] = useState('');
const [debouncedText, setDebouncedText] = useState('');
useEffect(() => {
const handle = setTimeout(() => setDebouncedText(text), 200);
return () => clearTimeout(handle);
}, [text]);
const handleContextMenuData = useCallback((payload: string) => {
setText(payload);
@@ -23,7 +17,7 @@ export function useTextStatistics(): UseTextStatisticsReturn {
useContextMenuData({ featureKey: 'textStatistics', onData: handleContextMenuData });
const stats = useMemo(() => getTextStats(debouncedText), [debouncedText]);
const stats = getTextStats(text);
return { text, stats, setText };
}
@@ -5,7 +5,6 @@ import Index from '../index';
describe('Timestamp 页面', () => {
it('应该渲染模式切换按钮', () => {
render(<Index />);
// 基本渲染测试:页面含多个模式切换按钮,应至少渲染一个
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
});
@@ -8,9 +8,15 @@ import {
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { MODE_OPTIONS, UNIT_OPTIONS, ZONES, type ZoneType } from '../constants';
import {
CARD_CLASS,
INPUT_PLACEHOLDERS,
MODE_OPTIONS,
UNIT_OPTIONS,
ZONES,
type ZoneType,
} from '../constants';
import type { UseTimestampConverterReturn } from '../useTimestampConverter';
import ToolCard from './ToolCard';
type ConverterFormProps = Omit<UseTimestampConverterReturn, 'result' | 'handleUseNow'>;
@@ -20,20 +26,19 @@ export default function ConverterForm({
unit,
zone,
error,
inputPlaceholder,
setMode,
setInput,
setUnit,
setZone,
}: ConverterFormProps) {
return (
<ToolCard className="gap-4">
<div className={cn(CARD_CLASS, 'gap-4')}>
<SwitchButtonGroup value={mode} options={MODE_OPTIONS} onChange={setMode} size="small" />
<div className="flex flex-col gap-1.5">
<Input
type="text"
placeholder={inputPlaceholder}
placeholder={INPUT_PLACEHOLDERS[mode]}
value={input}
onChange={(e) => setInput(e.target.value)}
className={cn(
@@ -71,6 +76,6 @@ export default function ConverterForm({
</SelectContent>
</Select>
</div>
</ToolCard>
</div>
);
}
+3 -3
View File
@@ -34,7 +34,7 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
{...props}
>
<span className="text-muted-foreground font-bold text-[10px] uppercase tracking-wider whitespace-nowrap shrink-0 selection:bg-transparent select-none">
{'当前时间戳'}
</span>
<span className="flex-1 font-mono font-bold text-foreground text-sm tracking-tight leading-none truncate tabular-nums">
@@ -47,7 +47,7 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
onUseNow(rawTime);
toast.success('已使用当前时间戳');
}}
title={'填充到下方'}
title="填充到下方"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
@@ -55,7 +55,7 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
<Clock className="w-3.5 h-3.5" />
</Button>
<CopyButton text={text} tooltip={'复制时间戳'} className="h-7 w-7 rounded-md border" />
<CopyButton text={text} tooltip="复制时间戳" className="h-7 w-7 rounded-md border" />
</div>
);
}
@@ -14,15 +14,16 @@ export default function ResultView({
className,
...props
}: ResultViewProps) {
if (!result && !showEmptyPlaceholder) return null;
if (!result) {
if (!showEmptyPlaceholder) return null;
return (
<EmptyPlaceholder
className={cn('flex-1 min-h-[320px]', className)}
messageClassName="text-sm font-medium text-muted-foreground max-w-none"
{...props}
>
{'请输入并点击转换'}
</EmptyPlaceholder>
);
}
@@ -30,7 +31,7 @@ export default function ResultView({
return (
<div className={cn('flex flex-col w-full', className)} {...props}>
<span className="block text-muted-foreground/90 mb-2.5 text-xs font-semibold tracking-wider uppercase">
{'转换结果'}
</span>
<div className="bg-card text-card-foreground border border-border p-4 sm:p-5 rounded-xl relative shadow-sm flex justify-between items-center gap-4 focus-within:ring-1 focus-within:ring-ring">
@@ -39,7 +40,7 @@ export default function ResultView({
</span>
<CopyButton
text={result}
tooltip={'复制结果'}
tooltip="复制结果"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
@@ -1,15 +0,0 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { CARD_CLASS } from '../constants';
interface ToolCardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export default function ToolCard({ children, className, ...props }: ToolCardProps) {
return (
<div className={cn(CARD_CLASS, className)} {...props}>
{children}
</div>
);
}
+4 -3
View File
@@ -1,7 +1,8 @@
import { cn } from '@/lib/utils';
import LiveClock from './components/LiveClock';
import ConverterForm from './components/ConverterForm';
import ResultView from './components/ResultView';
import ToolCard from './components/ToolCard';
import { CARD_CLASS } from './constants';
import { useTimestampConverter } from './useTimestampConverter';
export default function Index() {
@@ -14,9 +15,9 @@ export default function Index() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-stretch">
<ConverterForm {...formProps} />
<ToolCard className="h-full">
<div className={cn(CARD_CLASS, 'h-full')}>
<ResultView result={result} showEmptyPlaceholder />
</ToolCard>
</div>
</div>
</div>
);
+1 -3
View File
@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import dayjs from '@/utils/dayjs';
import type { UnitType, ZoneType, ModeType } from './constants';
import { DATE_FORMAT, INPUT_PLACEHOLDERS, msToUnit, dayjsFromTimestamp } from './constants';
import { DATE_FORMAT, msToUnit, dayjsFromTimestamp } from './constants';
import { useContextMenuData } from '@/utils/useContextMenuData';
export interface UseTimestampConverterReturn {
@@ -11,7 +11,6 @@ export interface UseTimestampConverterReturn {
zone: ZoneType;
result: string;
error: string;
inputPlaceholder: string;
setMode: (mode: ModeType) => void;
setInput: (value: string) => void;
@@ -101,7 +100,6 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
zone,
result,
error,
inputPlaceholder: INPUT_PLACEHOLDERS[mode],
setMode: handleSetMode,
setInput,
setUnit,
+15 -5
View File
@@ -28,36 +28,46 @@ React.StrictMode
- **当前页面**`currentPage``PageType`
- **可见页面列表**`visiblePages`
- **页面排序**`pageOrder`
- **最近使用工具**`recentlyUsedTools`(最多 3 项,供 TopBar 搜索历史使用)
- **加载状态**`isLoaded`
核心特性:
- 通过 `chrome.storage` 持久化路由状态
- 使用 `localStorage` 快照实现首屏 0 闪烁
- 使用 `localStorage` 快照`snapshot/{key}`实现首屏 0 闪烁
- 支持 popup/sidepanel/tab 三种入口的独立路由同步(通过 `syncKey``visiblePagesKey``pageOrderKey` 配置)
- 处理右键菜单待处理数据的路由跳转
- 监听 `chrome.storage.onChanged` 实现跨端同步
### 初始化与防覆盖
异步加载 storage 期间,快照值会作为首屏初始 state。加载完成后:
1. **`canPersistRef`**:仅在 `loadInitialData` 成功后才设为 `true`,在此之前不会向 storage 写入,避免默认值覆盖已有路由
2. **`hasUserNavigatedRef`**:用户调用 `navigateTo` / `goHome` 后设为 `true`,异步加载结果不会覆盖用户已选页面
3. **`mergeWithDefaults`**:将已保存的页面列表与默认列表合并,新增功能会自动出现在列表末尾
导出:
- `RouterProvider` 组件
- `useRouter()` Hook — 获取 `currentPage``visiblePages``pageOrder``navigateTo``goHome`
- `useRouter()` Hook — 获取 `currentPage``visiblePages``pageOrder``recentlyUsedTools``navigateTo``goHome`
## ThemeModeProvider.tsx
主题模式 Provider,管理:
- **主题模式**`light` / `dark` / `system`
- **解析后的主题**`resolvedTheme``light` / `dark`
- **解析后的主题**`resolvedMode``light` / `dark`
核心特性:
- 使用 `localStorage` 快照实现首屏 0 闪烁
- 使用 `themeSnapshot.ts` 读写 `localStorage` 快照实现首屏 0 闪烁
- 监听系统级暗色模式变化(`matchMedia`
- 通过 `chrome.storage` 跨端同步主题偏好
- 自动在 `document.documentElement` 上切换 `dark` class
- **`hasUserSetMode`**:用户主动切换主题后,异步 storage 加载不会覆盖用户选择
导出:
- `ThemeModeProvider` 组件
- `useThemeMode()` Hook — 获取 `themeMode``resolvedTheme``setThemeMode`
- `useThemeMode()` Hook — 获取 `mode``resolvedMode``setMode`
+16 -13
View File
@@ -105,10 +105,9 @@ export function RouterProvider({
);
const [isLoaded, setIsLoaded] = useState(false);
const hasUserNavigatedRef = useRef(false);
const canPersistRef = useRef(false);
/**
* 从异步存储中安全溯源初始数据
*/
const loadInitialData = useCallback(async () => {
try {
const savedRoute = await storageUtil.get(syncKey, defaultRoute);
@@ -119,7 +118,7 @@ export function RouterProvider({
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
if (isValidPage(savedRoute) && syncRoute) {
if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
setCurrentPage(savedRoute);
}
if (isValidPageList(savedVisiblePages)) {
@@ -131,9 +130,11 @@ export function RouterProvider({
if (isValidPageList(savedRecentTools)) {
setRecentlyUsedTools(savedRecentTools);
}
setIsLoaded(true);
canPersistRef.current = true;
} catch (error) {
console.error('[Router Init Error] Core data fetch failed:', error);
} finally {
setIsLoaded(true);
}
}, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]);
@@ -145,7 +146,6 @@ export function RouterProvider({
.then(() => {
if (cancelled) return;
// Check URL params for context menu data
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const feature = params.get('feature') as PageType | null;
@@ -165,7 +165,6 @@ export function RouterProvider({
}
}
// Check storage for pending context menu data
storageUtil
.get('contextMenu/pendingData')
.then((pendingData) => {
@@ -187,8 +186,8 @@ export function RouterProvider({
}, [loadInitialData]);
useEffect(() => {
if (isLoaded && syncRoute) {
void storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
if (isLoaded && canPersistRef.current && syncRoute) {
void storageUtil.set(syncKey, currentPage).catch(console.error);
try {
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
} catch (err) {
@@ -198,7 +197,7 @@ export function RouterProvider({
}, [currentPage, isLoaded, syncRoute, syncKey]);
useEffect(() => {
if (isLoaded) {
if (isLoaded && canPersistRef.current) {
void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error);
try {
localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages));
@@ -209,7 +208,7 @@ export function RouterProvider({
}, [visiblePages, isLoaded, visiblePagesKey]);
useEffect(() => {
if (isLoaded) {
if (isLoaded && canPersistRef.current) {
void storageUtil.set(pageOrderKey, pageOrder).catch(console.error);
try {
localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder));
@@ -220,7 +219,7 @@ export function RouterProvider({
}, [pageOrder, isLoaded, pageOrderKey]);
useEffect(() => {
if (isLoaded) {
if (isLoaded && canPersistRef.current) {
void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error);
try {
localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools));
@@ -283,6 +282,8 @@ export function RouterProvider({
}, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]);
const navigateTo = (page: PageType) => {
hasUserNavigatedRef.current = true;
canPersistRef.current = true;
setCurrentPage(page);
setRecentlyUsedTools((prev) => {
const filtered = prev.filter((p) => p !== page);
@@ -291,6 +292,8 @@ export function RouterProvider({
};
const goHome = () => {
hasUserNavigatedRef.current = true;
canPersistRef.current = true;
setCurrentPage('dashboard');
};
@@ -316,7 +319,7 @@ export function RouterProvider({
export function useRouter() {
const context = useContext(RouterContext);
if (!context) {
throw new Error('useRouter must be used within a valid RouterProvider context wrapper');
throw new Error('useRouter must be used within RouterProvider');
}
return context;
}
+3 -5
View File
@@ -59,7 +59,7 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
applyResolvedThemeClass(resolveThemeMode(next));
void storageUtil.set(THEME_MODE_STORAGE_KEY, next).catch((err) => {
console.error('[Theme Storage Error] Failed to persistent theme state:', err);
console.error('[Theme Storage Error] Failed to persist theme mode:', err);
});
persistThemeModeSnapshot(next);
@@ -82,7 +82,7 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
applyResolvedThemeClass(resolveThemeMode(saved));
})
.catch((err) => {
console.error('[Theme Restore Thread Failed]', err);
console.error('[Theme Restore Error]', err);
});
return () => {
@@ -135,9 +135,7 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
export function useThemeMode(): ThemeModeContextType {
const ctx = useContext(ThemeModeContext);
if (!ctx) {
throw new Error(
'useThemeMode must be used within a valid ThemeModeProvider sandboxed container',
);
throw new Error('useThemeMode must be used within ThemeModeProvider');
}
return ctx;
}
@@ -323,6 +323,51 @@ describe('RouterProvider', () => {
});
});
it('初始化失败时仍应解除加载状态以便渲染页面', async () => {
(storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable'));
render(
<RouterProvider>
<TestComponent />
</RouterProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard');
});
expect(storageUtil.set).not.toHaveBeenCalled();
});
it('用户在 loadInitialData 完成前导航时不应被存储路由覆盖', async () => {
let resolveGet: () => void;
const getBlocked = new Promise<void>((resolve) => {
resolveGet = resolve;
});
(storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => {
await getBlocked;
if (key === 'app/currentRoute') return 'dashboard';
return defaultValue;
});
render(
<RouterProvider>
<TestComponent />
</RouterProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId('navigate-btn'));
});
expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp');
resolveGet!();
await waitFor(() => {
expect(screen.getByTestId('current-page')).toHaveTextContent('timestamp');
expect(storageUtil.set).toHaveBeenCalledWith('app/currentRoute', 'timestamp');
});
});
it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => {
let resolveStorage: (value: unknown) => void;
const storagePromise = new Promise((resolve) => {
+11 -3
View File
@@ -10,12 +10,10 @@
**页面类型:**
- `PageType` — 所有页面类型的联合类型(`dashboard` | `timestamp` | `storageCleaner` | ...
- `PageType` — 所有页面类型的联合类型(`dashboard``timestamp``storageCleaner``testDataGenerator`
- `JsonToolsPageMode` — JSON 工具子模式(`diff` | `format` | `yaml` | `toml` | `minify`
- `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image`
- `Base64ConvertDirection` — 编解码方向(`encode` | `decode`
- `MarkdownToHtmlPreviewMode` — Markdown 预览模式(`split` | `preview` | `html`
- `HtmlToMarkdownPreviewMode` — HTML 预览模式(`split` | `preview` | `markdown`
**存储 Schema**
@@ -34,6 +32,16 @@
`qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。
### testDataGenerator.ts
测试数据生成器共享类型,包含:
- `FieldConfig` — 字段配置(字段名、生成器、参数、必填、空值率、唯一性)
- `DataRule` — 可保存/导入/导出的字段规则
- `GeneratorDefinition` / `GeneratorParam` — 内置生成器定义和参数 Schema
- `GenerateResult` / `GenerateProgress` / `WorkerMessage` — Worker 生成结果、进度和消息协议
- `ExportFile` — JSON/CSV 导出文件描述
## 修改 StorageSchema 的注意事项
修改 `StorageSchema` 时,必须:
+15 -5
View File
@@ -156,19 +156,29 @@ export interface GenerateProgress {
}
/**
* Worker 消息类型
* Worker 请求消息类型
*/
export type WorkerMessage =
export type WorkerRequestMessage =
| { type: 'start'; payload: WorkerStartPayload }
| { type: 'progress'; payload: GenerateProgress }
| { type: 'complete'; payload: GenerateResult }
| { type: 'error'; payload: { error: string } }
| { type: 'cancel' };
/**
* Worker 响应消息类型
*/
export type WorkerResponseMessage =
| { type: 'progress'; generationId: number; payload: GenerateProgress }
| { type: 'complete'; generationId: number; payload: GenerateResult }
| { type: 'error'; generationId: number; payload: { error: string } };
/** @deprecated 使用 WorkerRequestMessage | WorkerResponseMessage */
export type WorkerMessage = WorkerRequestMessage | WorkerResponseMessage;
/**
* Worker 启动参数
*/
export interface WorkerStartPayload {
/** 生成任务 ID,用于忽略过期 Worker 响应 */
generationId: number;
/** 字段配置列表 */
fields: FieldConfig[];
/** 生成数量 */
+34 -21
View File
@@ -4,39 +4,52 @@
## 工具函数
| 文件 | 用途 |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
| `contextMenu.ts` | 右键菜单配置与操作:定义菜单项、创建菜单、解析点击事件、ID→PageType 映射 |
| `base64Converter.ts` | Base64 编解码:文本↔Base64、文件↔Base64、图片预览,定义文件大小限制和图像 MIME 类型 |
| `jwt.ts` | JWT 解析:Base64URL 解码、解析 Header/Payload/Signature、JSON 格式化输出 |
| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify |
| `jsonToYaml.ts` | JSON→YAML 转换 |
| `jsonToToml.ts` | JSON→TOML 转换 |
| `markdownToHtml.ts` | Markdown→HTML 转换:基于 `marked` 库,支持 GFM 和换行转换 |
| `htmlToMarkdown.ts` | HTML→Markdown 转换:基于 DOMParser 解析 |
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 |
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB |
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
| 文件 | 用途 |
| ------------------------ | --------------------------------------------------------------------------------------------------- |
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
| `syncSnapshot.ts` | 通用 `localStorage` 快照读取(`snapshot/{key}`),用于 Router 与 `useStorageState` 首屏防闪烁 |
| `themeSnapshot.ts` | 主题专用快照读写与 `document.documentElement` class 切换 |
| `restrictedUrls.ts` | 受限 URL 检测(`chrome://``about:` 等),供 Storage Cleaner 等模块复用 |
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
| `contextMenu.ts` | 右键菜单配置与操作:定义菜单项、创建菜单、解析点击事件、ID→PageType 映射 |
| `base64Converter.ts` | Base64 编解码:文本↔Base64、文件↔Base64、图片预览,定义文件大小限制和图像 MIME 类型 |
| `jwt.ts` | JWT 解析:Base64URL 解码、解析 Header/Payload/Signature、JSON 格式化输出 |
| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify |
| `jsonToYaml.ts` | JSON→YAML 转换 |
| `jsonToToml.ts` | JSON→TOML 转换 |
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
| `storageCleaner.ts` | 存储清理:获取当前标签页、计算 Cookie/Storage 大小、清理操作 |
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB |
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
| `ruleStorage.ts` | 测试数据生成器规则存储:基于 `localStorage` 的 CRUD、搜索、导入/导出和数量限制 |
| `dataExporter.ts` | 测试数据导出:JSON/CSV 转换、文件下载和复制到剪贴板 |
| `rightClickInjection.ts` | 右键恢复注入脚本:在页面上下文恢复 contextmenu/copy/paste 等事件默认行为 |
## 自定义 Hooks
| 文件 | 用途 |
| ----------------------- | -------------------------------------------------------------------------------------------------------------- |
| `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 |
| `useLazyTranslation.ts` | 懒加载翻译 Hook:按需动态导入 i18n 命名空间,支持预加载和缓存 |
| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 |
| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 |
### useStorageState 初始化防覆盖
`useStorageState` 在挂载时从 storage 异步加载。写入 storage 需满足以下任一条件:
- **`loadSucceededRef`**storage 读取成功
- **`userModifiedRef`**:用户通过 setter 主动修改过值
若 storage 读取失败且用户未修改,不会将默认值写回 storage,避免静默覆盖已有数据。
## 使用约定
- 工具函数使用**命名导出**`export function xxx()`
- 工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象
- 工具层不直接展示 Toast;可恢复错误返回可判断结果,需要抛出的解析/转换错误由页面 Hook 或 UI 层捕获
- Hook 使用 `use` 前缀命名,定义返回值接口类型
- 存储操作使用 `chromeStorage.ts``storageUtil` 封装,不要直接调用 `chrome.storage`
- 消息通信使用 `messages.ts``sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage`
- 需要首屏快照的新持久化状态,优先复用 `syncSnapshot.ts` 或参考 `themeSnapshot.ts` 模式
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as ruleStorage from '../ruleStorage';
const STORAGE_KEY = 'testDataGenerator_rules';
const mockField = {
id: 'field-1',
name: 'username',
generatorId: 'string',
params: {},
required: true,
nullRate: 0,
unique: false,
};
describe('ruleStorage', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
it('save 在 localStorage 写入失败时应返回 null', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
const result = ruleStorage.save({
name: 'Test Rule',
fields: [mockField],
});
expect(result).toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('update 在 localStorage 写入失败时应返回 null', () => {
const saved = ruleStorage.save({
name: 'Test Rule',
fields: [mockField],
});
expect(saved).not.toBeNull();
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
const updated = ruleStorage.update(saved!.id, { name: 'Updated Rule' });
expect(updated).toBeNull();
});
it('deleteRule 在 localStorage 写入失败时应返回 false', () => {
const saved = ruleStorage.save({
name: 'Test Rule',
fields: [mockField],
});
expect(saved).not.toBeNull();
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('QuotaExceededError');
});
expect(ruleStorage.deleteRule(saved!.id)).toBe(false);
expect(ruleStorage.getById(saved!.id)).toBeDefined();
});
});
@@ -166,4 +166,18 @@ describe('useStorageState', () => {
expect(result.current[2]).toBe(false);
});
it('加载失败时不应把快照默认值写回 Chrome Storage', async () => {
localStorage.setItem('snapshot/app/searchHistory', JSON.stringify([]));
(storageUtil.get as any).mockRejectedValue(new Error('Storage read failed'));
renderHook(() =>
useStorageState('app/searchHistory', [], (val): val is string[] => Array.isArray(val)),
);
await waitFor(() => {
expect(storageUtil.set).not.toHaveBeenCalled();
});
});
});
+1 -5
View File
@@ -1,4 +1,4 @@
import type { DiffNode, DiffResult, DiffType } from '@/pages/JsonTools/types';
import type { DiffNode, DiffResult } from '@/pages/JsonTools/types';
const ROOT_PATH = '$';
const SENTINEL = Symbol('missing');
@@ -37,7 +37,6 @@ const diffNode = (
path: string,
diffPaths: string[],
): DiffNode => {
// 分支 1:节点增加行为拦截 (叶子节点状态)
if (left === SENTINEL && right !== SENTINEL) {
diffPaths.push(path);
return {
@@ -51,7 +50,6 @@ const diffNode = (
};
}
// 分支 2:节点删除行为拦截 (叶子节点状态)
if (right === SENTINEL && left !== SENTINEL) {
diffPaths.push(path);
return {
@@ -181,5 +179,3 @@ export const diffJson = (left: unknown, right: unknown): DiffResult => {
diffCount: diffPaths.length,
};
};
export type { DiffNode, DiffResult, DiffType };
+5 -17
View File
@@ -70,12 +70,12 @@ export async function clearAllIndexedDBs(
const deleteReq = indexedDB.deleteDatabase(dbName);
const timeoutId = setTimeout(() => {
console.warn('IndexedDB delete timeout:', dbName);
console.warn(`IndexedDB 删除超时: ${dbName}`);
settle('timeout');
}, timeoutMs);
deleteReq.onblocked = () => {
console.warn('IndexedDB delete blocked:', dbName);
console.warn(`IndexedDB 删除被阻塞: ${dbName}`);
settle('blocked');
};
deleteReq.onsuccess = () => settle('deleted');
@@ -137,7 +137,7 @@ export async function clearAllIndexedDBs(
const transaction = db.transaction(storeNames, 'readwrite');
// 必须在 clear 请求完成前注册 oncomplete,否则事务可能已结束导致永久挂起
const transactionDone = waitForTransaction(transaction, clearStoreTimeoutMs);
void transactionDone.catch(() => undefined);
void transactionDone.catch(() => {});
const errors = (
await Promise.all(
storeNames.map((storeName) => clearStore(transaction.objectStore(storeName), storeName)),
@@ -145,23 +145,11 @@ export async function clearAllIndexedDBs(
).filter((error): error is string => Boolean(error));
if (errors.length > 0) {
try {
transaction.abort();
} catch {
// ignore abort failures on already-finished transactions
}
transaction.abort();
return { success: false, errors };
}
try {
await transactionDone;
} catch {
return {
success: false,
errors: [`清空 IndexedDB 失败(${dbName}),请刷新后重试`],
};
}
await transactionDone;
return { success: true, errors: [] };
} catch {
return {

Some files were not shown because too many files have changed in this diff Show More