Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent f09d438f0f docs: 同步 i18n 移除与存储初始化防覆盖说明
- 移除已废弃的 chrome.i18n / _locales 文档引用
- 补充 UI 文案规范(features.tsx + 组件内中文)
- 记录 RouterProvider / useStorageState 初始化防覆盖机制
- 更新 syncSnapshot、themeSnapshot、restrictedUrls 工具说明

Co-authored-by: LingandRX <LingandRX@users.noreply.github.com>
2026-06-27 02:31:19 +00: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
29 changed files with 453 additions and 520 deletions
+88 -84
View File
@@ -265,12 +265,11 @@ const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
2. 第三方库(图标、UI 库等) 2. 第三方库(图标、UI 库等)
3. 业务 Provider / Context 3. 业务 Provider / Context
4. 配置 / 存储 4. 配置 / 存储
5. i18n 5. 本地页面组件
6. 本地页面组件 6. UI 组件
7. UI 组件 7. 工具函数 / Hook
8. 工具函数 / Hook 8. 类型
9. 类型 9. 常量
10. 常量
```typescript ```typescript
// 1. React 核心 // 1. React 核心
@@ -284,19 +283,16 @@ import { useThemeMode } from '@/providers/ThemeModeProvider';
// 4. 配置 / 存储 // 4. 配置 / 存储
import { FeatureConfig, FEATURES } from '@/config/features'; import { FeatureConfig, FEATURES } from '@/config/features';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
// 5. i18n // 5. 本地组件
import { useTranslation } from 'react-i18next';
import { normalizeLanguage, SUPPORTED_LANGUAGES } from '@/i18n';
// 6. 本地组件
import TextMode from './TextMode'; import TextMode from './TextMode';
import { ZONES } from './constants'; import { ZONES } from './constants';
// 7. UI 组件 // 6. UI 组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
// 8. 工具函数 / Hook // 7. 工具函数 / Hook
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useStorageState } from '@/utils/useStorageState';
// 9. 类型 // 8. 类型
import type { PageType, StorageSchema } from '@/types/storage'; import type { PageType, StorageSchema } from '@/types/storage';
``` ```
@@ -401,22 +397,22 @@ className={cn(
## 5. 错误处理 ## 5. 错误处理
### 5.1 工具函数:结果对象模式 ### 5.1 工具函数:可恢复错误返回可判断结果
工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象: 可恢复的解析/校验错误应返回可判断的结果,避免工具层直接弹 Toast。确需保留底层异常的函数
(如 `formatJson` / `minifyJson`)必须在页面 Hook 或 UI 层捕获并转换为用户提示:
```typescript ```typescript
// ✅ 结果对象模式 // ✅ 可恢复校验返回错误消息,调用方据此展示 UI
export function markdownToHtml(markdown: string): MarkdownToHtmlResult { export function validateJson(text: string): string | null {
if (!text.trim()) {
return null;
}
try { try {
... JSON.parse(text.trim());
return { html, originalLength, htmlLength, hasError: false }; return null;
} catch (error) { } catch (e) {
return { return e instanceof SyntaxError ? e.message : 'Invalid JSON';
html: '', ...
hasError: true,
error: error instanceof Error ? error.message : 'Markdown 解析失败',
};
} }
} }
``` ```
@@ -560,7 +556,7 @@ describe('SwitchButtonGroup 组件', () => {
- 使用 `vi.mock()` 进行模块级 Mock - 使用 `vi.mock()` 进行模块级 Mock
- 使用 `vi.fn()` 进行函数级 Mock - 使用 `vi.fn()` 进行函数级 Mock
- 使用 `vi.useFakeTimers()` 控制时间 - 使用 `vi.useFakeTimers()` 控制时间
- **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、i18n、matchMedia 等) - **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、matchMedia 等)
```typescript ```typescript
// ✅ 模块级 Mock // ✅ 模块级 Mock
@@ -658,7 +654,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... }
``` ```
src/utils/useStorageState.ts — Chrome Storage 状态持久化 src/utils/useStorageState.ts — Chrome Storage 状态持久化
src/utils/useLazyTranslation.tsi18n 懒加载 src/utils/syncSnapshot.ts localStorage 快照读取
src/utils/useContextMenuData.ts — 右键菜单数据 src/utils/useContextMenuData.ts — 右键菜单数据
src/utils/useDebounce.ts — 防抖 src/utils/useDebounce.ts — 防抖
src/pages/Timestamp/useTimestampConverter.ts — 页面级 Hook 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` ### 9.1 功能元数据
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
### 9.2 翻译文件结构 功能名称与描述在 `config/features.tsx``FEATURES` 数组中定义:
```
i18n/locales/{zh,en}/common.json — 全局通用翻译
i18n/locales/{zh,en}/features.json — 功能模块标题和描述
i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
```
### 9.3 使用方式
```typescript ```typescript
// ✅ 页面组件 — 使用 useLazyTranslation {
import { useLazyTranslation } from '@/utils/useLazyTranslation'; key: 'timestamp',
label: '时间戳转换',
export default function Index() { description: '日期与时间戳互转',
const { t } = useLazyTranslation('timestamp'); defaultVisible: true,
return <h1>{t('timestamp:title')}</h1>; components: { popup: TimestampPage, sidepanel: TimestampPage, tab: TimestampPage },
}
// ✅ 全局组件 — 使用 useTranslation
import { useTranslation } from 'react-i18next';
export function TopBar() {
const { t } = useTranslation(['common', 'features']);
return <span>{t('common:settings')}</span>;
} }
``` ```
### 9.4 添加新翻译 Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description`
1.`i18n/locales/{zh,en}/features.json` 添加功能标题和描述 ### 9.2 页面与组件文案
2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3.`utils/useLazyTranslation.ts``localeModules` 中注册新命名空间 - 页面标题、按钮、提示信息等直接在 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. 页面开发规范 ## 11. 页面开发规范
@@ -842,24 +853,21 @@ export default function DashboardPage() { ... }
#### 组件职责 #### 组件职责
`index.tsx` 只负责件事: `index.tsx` 只负责件事:
1. **获取翻译函数**`useI18n` 1. **调用业务 Hook** 获取状态和操作方法
2. **调用业务 Hook** 获取状态和操作方法 2. **渲染 UI 布局**(纯展示,无业务逻辑)
3. **渲染 UI 布局**(纯展示,无业务逻辑)
```typescript ```typescript
// ✅ 标准页面入口模板 // ✅ 标准页面入口模板
import { useI18n } from '@/utils/chromeI18n';
import { useFeatureName } from './useFeatureName'; import { useFeatureName } from './useFeatureName';
export default function Index() { export default function Index() {
const { t } = useI18n('featureName');
const { state, actions } = useFeatureName(); const { state, actions } = useFeatureName();
return ( return (
<div className="p-4 w-full flex flex-col space-y-4 select-none"> <div className="p-4 w-full flex flex-col space-y-4 select-none">
{/* 纯 UI 渲染 */} {/* 纯 UI 渲染,文案直接写中文 */}
</div> </div>
); );
} }
@@ -903,25 +911,22 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
```typescript ```typescript
export function useFeatureName(): UseFeatureNameReturn { export function useFeatureName(): UseFeatureNameReturn {
// 1. i18n // 1. 基础 stateuseState
const { t } = useI18n('featureName');
// 2. 基础 stateuseState
const [mode, setMode] = useState<Mode>('default'); const [mode, setMode] = useState<Mode>('default');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
// 3. 持久化 stateuseStorageState // 2. 持久化 stateuseStorageState
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode); const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode);
// 4. 衍生数据(useMemo)— 响应式计算管线 // 3. 衍生数据(useMemo)— 响应式计算管线
const result = useMemo(() => { const result = useMemo(() => {
// 自动计算,无需手动点击"转换"按钮 // 自动计算,无需手动点击"转换"按钮
}, [input, mode]); }, [input, mode]);
// 5. 事件处理(useCallback // 4. 事件处理(useCallback
const handleAction = useCallback(() => { ... }, [deps]); const handleAction = useCallback(() => { ... }, [deps]);
// 6. 副作用(useEffect)— 防抖、初始化、清理 // 5. 副作用(useEffect)— 防抖、初始化、清理
useEffect(() => { ... }, [deps]); useEffect(() => { ... }, [deps]);
// 7. 右键菜单数据(页面需要时) // 7. 右键菜单数据(页面需要时)
@@ -991,7 +996,7 @@ interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
// ✅ 使用 React.memo + displayName // ✅ 使用 React.memo + displayName
const ResultView = React.memo( const ResultView = React.memo(
({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => { ({ result, mode, showEmptyPlaceholder = false, className, ...props }: ResultViewProps) => {
const { t } = useI18n('featureName'); // 文案直接写中文或使用 constants
// ... // ...
}, },
); );
@@ -1001,7 +1006,7 @@ export default ResultView;
#### 子组件内可以使用 Hook #### 子组件内可以使用 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); typeof val === 'string' && (VALID_MODES as readonly string[]).includes(val);
export default function Index() { export default function Index() {
const { t } = useI18n('featureName');
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'modeA', isValidMode); const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'modeA', isValidMode);
return ( return (
@@ -1046,8 +1050,8 @@ export default function Index() {
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
{ value: 'modeA', label: t('feature:modeA') }, { value: 'modeA', label: '模式 A' },
{ value: 'modeB', label: t('feature:modeB') }, { value: 'modeB', label: '模式 B' },
]} ]}
onChange={(v: PageMode) => setPageMode(v)} onChange={(v: PageMode) => setPageMode(v)}
size="small" size="small"
@@ -1078,12 +1082,12 @@ export default function Index() {
新增功能页面时,逐项确认: 新增功能页面时,逐项确认:
1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型 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` 作为组件名 3. ✅ 创建页面目录,使用 `Index` 作为组件名
4. ✅ 业务逻辑提取到 `useXxx.ts` Hookindex.tsx 不超过 150 行) 4. ✅ 业务逻辑提取到 `useXxx.ts` Hookindex.tsx 不超过 150 行)
5. ✅ 需要持久化的 UI 状态使用 `useStorageState` 5. ✅ 需要持久化的 UI 状态使用 `useStorageState`
6. ✅ 常量 ≥3 个时提取到 `constants.ts` 6. ✅ 常量 ≥3 个时提取到 `constants.ts`
7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译 7. ✅ 在页面组件或 `constants.ts` 中编写 UI 文案
8. ✅ 创建 `__tests__/index.test.tsx` 测试文件 8. ✅ 创建 `__tests__/index.test.tsx` 测试文件
9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions` 9. ✅ 如需新权限,更新 `wxt.config.ts` 的 `manifest.permissions`
10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过 10. ✅ 运行 `npm run lint && npm run typecheck && npm run test` 全部通过
@@ -1103,8 +1107,8 @@ export default function Index() {
| `src/hooks/` | 自定义 React Hooks | | `src/hooks/` | 自定义 React Hooks |
| `src/utils/` | 工具函数与服务抽象 | | `src/utils/` | 工具函数与服务抽象 |
| `src/types/` | TypeScript 类型声明 | | `src/types/` | TypeScript 类型声明 |
| `src/lib/` | 通用工具函数(cn、utils | | `src/lib/` | 通用工具函数与生成器库cn、utils、generators |
| `public/` | 静态资源 | | `public/` | 静态资源(图标等) |
--- ---
+56 -20
View File
@@ -1,23 +1,6 @@
# AGENTS.md # AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。为开发者和测试人员提供浏览器内效率工具 WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。
## 功能列表
功能定义见 `src/config/features.tsx``PageType``src/types/storage.d.ts`
| key | 页面目录 | 说明 |
| -------------------- | -------------------------- | ------------------------------------------- |
| `dashboard` | `pages/Dashboard` | 仪表盘首页,工具导航与最近使用 |
| `timestamp` | `pages/Timestamp` | Unix 时间戳转换与格式化 |
| `storageCleaner` | `pages/StorageCleaner` | 清理 Cookies、本地存储、IndexedDB、Cache 等 |
| `qrCode` | `pages/QrCode` | 二维码生成与解析 |
| `textStatistics` | `pages/TextStatistics` | 文本字符、单词、行数、字节统计 |
| `jwt` | `pages/Jwt` | JWT 解码与查看 |
| `jsonTools` | `pages/JsonTools` | JSON 差异比较、格式化、YAML/TOML 转换与压缩 |
| `base64Converter` | `pages/Base64Converter` | 文本、文件、图像 Base64 编解码 |
| `rightClickRestorer` | `pages/RightClickRestorer` | 恢复被网站禁用的右键菜单 |
| `testDataGenerator` | `pages/TestDataGenerator` | 自定义规则批量生成测试数据 |
## 核心命令 ## 核心命令
@@ -78,7 +61,7 @@ src/ # 源代码根目录
lib/ # 通用工具函数(cn、utils)及数据生成器定义 lib/ # 通用工具函数(cn、utils)及数据生成器定义
workers/ # Web Worker(数据生成等耗时任务) workers/ # Web Worker(数据生成等耗时任务)
spec/ # 功能规格、修复方案与验收标准(见 spec/README.md spec/ # 功能规格、修复方案与验收标准(见 spec/README.md
public/ # 静态资源(图标、_locales 等) public/ # 静态资源(图标等)
.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑) .wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑)
.output/ # 生产构建输出目录 .output/ # 生产构建输出目录
``` ```
@@ -110,6 +93,38 @@ src/pages/FeatureName/
-`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式 -`index.tsx` 超过 150 行时,必须拆分为 UI + Hook 模式
- 复杂页面可增加 `contexts/``hooks/``components/` 子目录 - 复杂页面可增加 `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` **路由**: 不使用 React Router。通过 `src/config/features.tsx``FEATURES` 数组管理,`RouterProvider` 根据 `PageType`
@@ -117,7 +132,8 @@ src/pages/FeatureName/
每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。 每种模式有独立的路由和可见页面配置(`app/popupRoute``app/sidepanelRoute``app/tabRoute` 等)。
**存储**: 所有 Chrome Storage 键必须在 `src/types/storage.d.ts``StorageSchema` 中定义,键名使用 kebab-case 格式(如 `app/currentRoute`)。 **存储**: 所有 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` **通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
@@ -136,6 +152,26 @@ src/pages/FeatureName/
- Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码 - Mock 模式: 使用 `vi.mock()` 进行模块级 mock,避免在测试文件中重复 mock 代码
- 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试 - 测试工具: `@testing-library/react` + `@testing-library/user-event` 进行组件测试
## UI 文案
项目已移除 `chrome.i18n`UI 文案直接在代码中使用中文。
- **功能元数据**: `src/config/features.tsx``FEATURES` 数组定义 `label``description`(用于 Dashboard 卡片与搜索)
- **页面文案**: 在组件 JSX、`constants.ts` 或 Hook 中直接写中文
- **Manifest 文案**: 扩展名称与描述在 `wxt.config.ts``manifest` 中维护
- **Toast / 错误提示**: 在 Hook 或 `constants.ts` 中定义,使用 `sonner``toast()` 展示
## 新功能开发清单
1.`src/types/storage.d.ts` 添加 `PageType` 联合类型
2.`src/config/features.tsx``FEATURES` 数组添加配置(指定 key、label、description、图标、三种渲染模式的组件)
3.`src/pages/` 创建页面组件 (懒加载)
- `index.tsx` — UI 组件(纯展示)
- `useFeatureName.ts` — 业务逻辑 Hook
- `constants.ts` — 常量定义(可选,≥3 个常量时创建)
4. 如需新权限,更新 `wxt.config.ts``manifest.permissions`
5. 添加对应的单元测试
## 代码规范 ## 代码规范
- 禁止使用 `any` (测试文件除外) - 禁止使用 `any` (测试文件除外)
+3 -4
View File
@@ -81,7 +81,7 @@
- **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库) - **UI 组件**: shadcn/ui (基于 Radix UI 的无头组件库)
- **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数 - **样式**: Tailwind CSS + class-variance-authority + cn() 工具函数
- **日期处理**: dayjs (集成 UTC 和 Timezone 插件) - **日期处理**: dayjs (集成 UTC 和 Timezone 插件)
- **国际化**: i18next + react-i18next - **UI 文案**: 组件内直接使用中文字符串;功能名称与描述定义在 `src/config/features.tsx`
- **通信**: @webext-core/messaging - **通信**: @webext-core/messaging
- **存储**: Chrome Storage API (类型安全封装) - **存储**: Chrome Storage API (类型安全封装)
- **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成) - **解析引擎**: qr-scanner (二维码解析), qrious (二维码生成)
@@ -107,8 +107,8 @@
│ ├── hooks/ # 自定义 React Hooks │ ├── hooks/ # 自定义 React Hooks
│ ├── utils/ # 工具函数与服务抽象 │ ├── utils/ # 工具函数与服务抽象
│ ├── types/ # TypeScript 类型声明 │ ├── types/ # TypeScript 类型声明
│ └── lib/ # 通用工具函数 (cn, utils 等) │ └── lib/ # 通用工具函数与生成器库 (cn, utils, generators 等)
├── public/ # 静态资源 (图标、manifest 资源等) ├── public/ # 静态资源 (图标等)
├── wxt.config.ts # WXT 框架核心配置 ├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理 └── package.json # 项目元数据与依赖管理
``` ```
@@ -155,7 +155,6 @@
- `sidePanel`: 支持在浏览器侧边栏中运行. - `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能. - `clipboardWrite`: 提供一键复制功能.
- `contextMenus`: 注册右键菜单,支持快捷操作. - `contextMenus`: 注册右键菜单,支持快捷操作.
- `alarms`: 管理后台定时任务(如清理后自动刷新).
## 浏览器支持 ## 浏览器支持
+2 -5
View File
@@ -724,17 +724,14 @@ src/
```tsx ```tsx
// src/pages/NewTool/index.tsx // src/pages/NewTool/index.tsx
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { useI18n } from '@/utils/chromeI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export default function Index() { export default function Index() {
const { t } = useI18n('newTool');
return ( 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"> <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>
</div> </div>
); );
+11 -1
View File
@@ -2,7 +2,7 @@
> 版本: v1.0 > 版本: v1.0
> 创建时间: 2024-01-20 > 创建时间: 2024-01-20
> 状态: 待 Review > 状态: 已实现(见 `src/pages/TestDataGenerator/`、`src/lib/generators/`、`src/workers/generator.worker.ts`
## 产品定位 ## 产品定位
@@ -19,3 +19,13 @@
- [规则管理](./rule-management.md) - [规则管理](./rule-management.md)
- [界面设计](./ui-design.md) - [界面设计](./ui-design.md)
- [技术实现](./technical-implementation.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 配置 - 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
- 图标格式推荐使用 PNG,确保透明背景 - 图标格式推荐使用 PNG,确保透明背景
- UI 文案不在此目录维护,见 `src/config/features.tsx` 与各页面组件
+32 -2
View File
@@ -4,9 +4,10 @@
## 文件说明 ## 文件说明
| 文件 | 用途 | | 文件/目录 | 用途 |
| ---------- | ------------------------------------ | | ------------- | -------------------------------------- |
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 | | `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
| `generators/` | 测试数据生成器内置生成器库和分类注册表 |
## cn() ## cn()
@@ -24,3 +25,32 @@ import { cn } from '@/lib/utils';
``` ```
所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。 所有需要动态合并 Tailwind 类名的场景都应使用 `cn()`,而非手动拼接字符串。
## generators/
测试数据生成器的核心生成器库,供 `src/workers/generator.worker.ts` 在后台线程中按字段配置生成数据。
| 文件 | 用途 |
| -------------- | ---------------------------------------------- |
| `index.ts` | 聚合所有生成器,导出分类、查找和搜索函数 |
| `personal.ts` | 个人信息生成器(姓名、邮箱、手机号、身份证等) |
| `business.ts` | 业务数据生成器(订单号、价格、日期、状态等) |
| `technical.ts` | 技术数据生成器(UUID、IPv4、URL |
| `basic.ts` | 基础类型生成器(整数、浮点数、布尔值、字符串) |
| `types.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` 能展示该分类。
@@ -1,11 +1,8 @@
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act, waitFor } from '@testing-library/react';
import { useRightClickRestorer } from '../useRightClickRestorer'; import { useRightClickRestorer } from '../useRightClickRestorer';
import { sendMessageToContent } from '@/utils/messages'; import { sendMessageToContent } from '@/utils/messages';
import { getCurrentTab } from '@/utils/chromeTabs';
vi.mock('@/utils/chromeTabs', () => ({ const mockTabsQuery = vi.fn();
getCurrentTab: vi.fn(),
}));
vi.mock('@/utils/messages', () => ({ vi.mock('@/utils/messages', () => ({
MessageAction: { MessageAction: {
@@ -17,9 +14,8 @@ vi.mock('@/utils/messages', () => ({
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(getCurrentTab).mockResolvedValue({ mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]);
url: 'https://example.com/path', chrome.tabs.query = mockTabsQuery;
} as chrome.tabs.Tab);
vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false }); vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false });
}); });
@@ -35,7 +31,8 @@ describe('useRightClickRestorer', () => {
}); });
it('should mark internal pages as unsupported', async () => { it('should mark internal pages as unsupported', async () => {
vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://newtab/' } as chrome.tabs.Tab); mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]);
chrome.tabs.query = mockTabsQuery;
const { result } = renderHook(() => useRightClickRestorer()); const { result } = renderHook(() => useRightClickRestorer());
@@ -62,7 +59,8 @@ describe('useRightClickRestorer', () => {
}); });
it('should not unlock unsupported pages', async () => { it('should not unlock unsupported pages', async () => {
vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://settings/' } as chrome.tabs.Tab); mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]);
chrome.tabs.query = mockTabsQuery;
const { result } = renderHook(() => useRightClickRestorer()); const { result } = renderHook(() => useRightClickRestorer());
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { getCurrentTab } from '@/utils/chromeTabs';
import { MessageAction, sendMessageToContent } from '@/utils/messages'; import { MessageAction, sendMessageToContent } from '@/utils/messages';
import { isUnsupportedPageUrl } from '@/utils/restrictedUrls'; import { isUnsupportedPageUrl } from '@/utils/restrictedUrls';
import type { RestorerStatus } from './constants'; import type { RestorerStatus } from './constants';
@@ -25,7 +24,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
const tab = await getCurrentTab(); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const url = tab?.url; const url = tab?.url;
setDomain(url ? new URL(url).hostname : ''); setDomain(url ? new URL(url).hostname : '');
@@ -2,8 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, it, expect, vi } from 'vitest'; import { beforeEach, describe, it, expect, vi } from 'vitest';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import Index from '../index'; import Index from '../index';
import { clearStorage, getCookieSize } from '@/utils/storageCleaner'; import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner';
import { getCurrentTab } from '@/utils/chromeTabs';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Mock the chrome APIs // Mock the chrome APIs
@@ -14,11 +13,8 @@ vi.mock('@/utils/chromeStorage', () => ({
}, },
})); }));
vi.mock('@/utils/chromeTabs', () => ({
getCurrentTab: vi.fn().mockResolvedValue({ id: 1, url: 'https://example.com' }),
}));
vi.mock('@/utils/storageCleaner', () => ({ vi.mock('@/utils/storageCleaner', () => ({
getCurrentTab: vi.fn().mockResolvedValue({ id: 1, url: 'https://example.com' }),
getCookieSize: vi.fn().mockResolvedValue(0), getCookieSize: vi.fn().mockResolvedValue(0),
getLocalStorageSize: vi.fn().mockResolvedValue(0), getLocalStorageSize: vi.fn().mockResolvedValue(0),
getSessionStorageSize: vi.fn().mockResolvedValue(0), getSessionStorageSize: vi.fn().mockResolvedValue(0),
@@ -107,4 +103,31 @@ describe('StorageCleaner 页面', () => {
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled(); 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('当前标签页已切换,请等待数据刷新后再清理');
});
});
}); });
+2 -1
View File
@@ -15,6 +15,7 @@ export default function Index() {
sizes, sizes,
reloadAfterClean, reloadAfterClean,
loading, loading,
isRefreshingSizes,
result, result,
showConfirm, showConfirm,
setShowConfirm, setShowConfirm,
@@ -26,7 +27,7 @@ export default function Index() {
handleClean, handleClean,
} = useStorageCleaner(); } = useStorageCleaner();
const isButtonDisabled = !(someSelected || allSelected) || loading; const isButtonDisabled = !(someSelected || allSelected) || loading || isRefreshingSizes;
if (isInitializing) { if (isInitializing) {
return ( return (
+15 -1
View File
@@ -10,12 +10,12 @@ import {
clearStorage, clearStorage,
getCacheStorageSize, getCacheStorageSize,
getCookieSize, getCookieSize,
getCurrentTab,
getLocalStorageSize, getLocalStorageSize,
getOriginStorageEstimate, getOriginStorageEstimate,
getServiceWorkerCount, getServiceWorkerCount,
getSessionStorageSize, getSessionStorageSize,
} from '@/utils/storageCleaner'; } from '@/utils/storageCleaner';
import { getCurrentTab } from '@/utils/chromeTabs';
import { isRestrictedUrl } from '@/utils/restrictedUrls'; import { isRestrictedUrl } from '@/utils/restrictedUrls';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -75,6 +75,7 @@ export interface UseStorageCleanerReturn {
sizes: Record<string, StorageSizeInfo>; sizes: Record<string, StorageSizeInfo>;
reloadAfterClean: boolean; reloadAfterClean: boolean;
loading: boolean; loading: boolean;
isRefreshingSizes: boolean;
result: CleaningResult | null; result: CleaningResult | null;
showConfirm: boolean; showConfirm: boolean;
setShowConfirm: (show: boolean) => void; setShowConfirm: (show: boolean) => void;
@@ -95,10 +96,12 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({}); const [sizes, setSizes] = useState<Record<string, StorageSizeInfo>>({});
const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true); const [reloadAfterClean, setReloadAfterClean] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
const [isRefreshingSizes, setIsRefreshingSizes] = useState<boolean>(false);
const [result, setResult] = useState<CleaningResult | null>(null); const [result, setResult] = useState<CleaningResult | null>(null);
const [showConfirm, setShowConfirm] = useState<boolean>(false); const [showConfirm, setShowConfirm] = useState<boolean>(false);
const requestIdRef = useRef<number>(0); const requestIdRef = useRef<number>(0);
const boundTabRef = useRef<{ id: number; url: string } | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const storageTimerRef = useRef<NodeJS.Timeout | null>(null); const storageTimerRef = useRef<NodeJS.Timeout | null>(null);
const loadingRef = useRef(loading); const loadingRef = useRef(loading);
@@ -117,6 +120,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
// 核心数据拉取链条 // 核心数据拉取链条
const loadInfo = useCallback(async () => { const loadInfo = useCallback(async () => {
const currentRequestId = ++requestIdRef.current; const currentRequestId = ++requestIdRef.current;
setIsRefreshingSizes(true);
try { try {
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (currentRequestId !== requestIdRef.current) return; if (currentRequestId !== requestIdRef.current) return;
@@ -159,6 +163,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
cacheStorage: { value: cacheCount, displayType: 'count' }, cacheStorage: { value: cacheCount, displayType: 'count' },
serviceWorkers: { value: swCount, displayType: 'count' }, serviceWorkers: { value: swCount, displayType: 'count' },
}); });
boundTabRef.current = { id: tabId, url };
} catch (err) { } catch (err) {
console.error('Failed to load storage cleaner info:', err); console.error('Failed to load storage cleaner info:', err);
if (currentRequestId === requestIdRef.current) { if (currentRequestId === requestIdRef.current) {
@@ -167,6 +172,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
} finally { } finally {
if (currentRequestId === requestIdRef.current) { if (currentRequestId === requestIdRef.current) {
setIsInitializing(false); setIsInitializing(false);
setIsRefreshingSizes(false);
} }
} }
}, []); }, []);
@@ -256,6 +262,13 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
return; return;
} }
const boundTab = boundTabRef.current;
if (!boundTab || boundTab.id !== tab.id) {
toast.warning('当前标签页已切换,请等待数据刷新后再清理');
setShowConfirm(false);
return;
}
setLoading(true); setLoading(true);
setShowConfirm(false); setShowConfirm(false);
try { try {
@@ -297,6 +310,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
sizes, sizes,
reloadAfterClean, reloadAfterClean,
loading, loading,
isRefreshingSizes,
result, result,
showConfirm, showConfirm,
setShowConfirm, setShowConfirm,
@@ -1,117 +0,0 @@
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' }]));
});
});
@@ -8,8 +8,7 @@ import type {
FieldConfig, FieldConfig,
GenerateResult, GenerateResult,
GenerateProgress, GenerateProgress,
WorkerRequestMessage, WorkerMessage,
WorkerResponseMessage,
} from '@/types/testDataGenerator'; } from '@/types/testDataGenerator';
export interface UseGeneratorReturn { export interface UseGeneratorReturn {
@@ -36,7 +35,6 @@ export function useGenerator(): UseGeneratorReturn {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const workerRef = useRef<Worker | null>(null); const workerRef = useRef<Worker | null>(null);
const generationIdRef = useRef(0);
// 清理 Worker // 清理 Worker
useEffect(() => { useEffect(() => {
@@ -60,30 +58,22 @@ export function useGenerator(): UseGeneratorReturn {
type: 'module', type: 'module',
}); });
worker.onmessage = (e: MessageEvent<WorkerResponseMessage>) => { worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
const data = e.data; const data = e.data;
const { type } = data; const { type } = data;
if (data.generationId !== generationIdRef.current) {
return;
}
switch (type) { switch (type) {
case 'progress': case 'progress':
setProgress(data.payload); setProgress(data.payload as GenerateProgress);
break; break;
case 'complete': case 'complete':
setIsGenerating(false); setIsGenerating(false);
if (data.payload.success) { setResult(data.payload as GenerateResult);
setResult(data.payload);
} else if (data.payload.error && data.payload.error !== '生成已取消') {
setError(data.payload.error);
}
setProgress(null); setProgress(null);
break; break;
case 'error': case 'error':
setIsGenerating(false); setIsGenerating(false);
setError(data.payload.error); setError((data.payload as { error: string }).error);
setProgress(null); setProgress(null);
break; break;
} }
@@ -110,17 +100,15 @@ export function useGenerator(): UseGeneratorReturn {
(fields: FieldConfig[], count: number, csvMode = false) => { (fields: FieldConfig[], count: number, csvMode = false) => {
if (isGenerating) return; if (isGenerating) return;
const generationId = ++generationIdRef.current;
setIsGenerating(true); setIsGenerating(true);
setProgress(null); setProgress(null);
setResult(null); setResult(null);
setError(null); setError(null);
const worker = getWorker(); const worker = getWorker();
const message: WorkerRequestMessage = { const message: WorkerMessage = {
type: 'start', type: 'start',
payload: { generationId, fields, count, csvMode }, payload: { fields, count, csvMode },
}; };
worker.postMessage(message); worker.postMessage(message);
}, },
@@ -132,8 +120,7 @@ export function useGenerator(): UseGeneratorReturn {
*/ */
const cancel = useCallback(() => { const cancel = useCallback(() => {
if (workerRef.current && isGenerating) { if (workerRef.current && isGenerating) {
++generationIdRef.current; const message: WorkerMessage = { type: 'cancel' };
const message: WorkerRequestMessage = { type: 'cancel' };
workerRef.current.postMessage(message); workerRef.current.postMessage(message);
setIsGenerating(false); setIsGenerating(false);
setProgress(null); setProgress(null);
+15 -5
View File
@@ -28,36 +28,46 @@ React.StrictMode
- **当前页面**`currentPage``PageType` - **当前页面**`currentPage``PageType`
- **可见页面列表**`visiblePages` - **可见页面列表**`visiblePages`
- **页面排序**`pageOrder` - **页面排序**`pageOrder`
- **最近使用工具**`recentlyUsedTools`(最多 3 项,供 TopBar 搜索历史使用)
- **加载状态**`isLoaded` - **加载状态**`isLoaded`
核心特性: 核心特性:
- 通过 `chrome.storage` 持久化路由状态 - 通过 `chrome.storage` 持久化路由状态
- 使用 `localStorage` 快照实现首屏 0 闪烁 - 使用 `localStorage` 快照`snapshot/{key}`实现首屏 0 闪烁
- 支持 popup/sidepanel/tab 三种入口的独立路由同步(通过 `syncKey``visiblePagesKey``pageOrderKey` 配置) - 支持 popup/sidepanel/tab 三种入口的独立路由同步(通过 `syncKey``visiblePagesKey``pageOrderKey` 配置)
- 处理右键菜单待处理数据的路由跳转 - 处理右键菜单待处理数据的路由跳转
- 监听 `chrome.storage.onChanged` 实现跨端同步 - 监听 `chrome.storage.onChanged` 实现跨端同步
### 初始化与防覆盖
异步加载 storage 期间,快照值会作为首屏初始 state。加载完成后:
1. **`canPersistRef`**:仅在 `loadInitialData` 成功后才设为 `true`,在此之前不会向 storage 写入,避免默认值覆盖已有路由
2. **`hasUserNavigatedRef`**:用户调用 `navigateTo` / `goHome` 后设为 `true`,异步加载结果不会覆盖用户已选页面
3. **`mergeWithDefaults`**:将已保存的页面列表与默认列表合并,新增功能会自动出现在列表末尾
导出: 导出:
- `RouterProvider` 组件 - `RouterProvider` 组件
- `useRouter()` Hook — 获取 `currentPage``visiblePages``pageOrder``navigateTo``goHome` - `useRouter()` Hook — 获取 `currentPage``visiblePages``pageOrder``recentlyUsedTools``navigateTo``goHome`
## ThemeModeProvider.tsx ## ThemeModeProvider.tsx
主题模式 Provider,管理: 主题模式 Provider,管理:
- **主题模式**`light` / `dark` / `system` - **主题模式**`light` / `dark` / `system`
- **解析后的主题**`resolvedTheme``light` / `dark` - **解析后的主题**`resolvedMode``light` / `dark`
核心特性: 核心特性:
- 使用 `localStorage` 快照实现首屏 0 闪烁 - 使用 `themeSnapshot.ts` 读写 `localStorage` 快照实现首屏 0 闪烁
- 监听系统级暗色模式变化(`matchMedia` - 监听系统级暗色模式变化(`matchMedia`
- 通过 `chrome.storage` 跨端同步主题偏好 - 通过 `chrome.storage` 跨端同步主题偏好
- 自动在 `document.documentElement` 上切换 `dark` class - 自动在 `document.documentElement` 上切换 `dark` class
- **`hasUserSetMode`**:用户主动切换主题后,异步 storage 加载不会覆盖用户选择
导出: 导出:
- `ThemeModeProvider` 组件 - `ThemeModeProvider` 组件
- `useThemeMode()` Hook — 获取 `themeMode``resolvedTheme``setThemeMode` - `useThemeMode()` Hook — 获取 `mode``resolvedMode``setMode`
+14 -6
View File
@@ -105,6 +105,8 @@ export function RouterProvider({
); );
const [isLoaded, setIsLoaded] = useState(false); const [isLoaded, setIsLoaded] = useState(false);
const hasUserNavigatedRef = useRef(false);
const canPersistRef = useRef(false);
/** /**
* 从异步存储中安全溯源初始数据 * 从异步存储中安全溯源初始数据
@@ -119,7 +121,7 @@ export function RouterProvider({
const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder()); const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder());
const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []); const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
if (isValidPage(savedRoute) && syncRoute) { if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
setCurrentPage(savedRoute); setCurrentPage(savedRoute);
} }
if (isValidPageList(savedVisiblePages)) { if (isValidPageList(savedVisiblePages)) {
@@ -131,9 +133,11 @@ export function RouterProvider({
if (isValidPageList(savedRecentTools)) { if (isValidPageList(savedRecentTools)) {
setRecentlyUsedTools(savedRecentTools); setRecentlyUsedTools(savedRecentTools);
} }
setIsLoaded(true); canPersistRef.current = true;
} catch (error) { } catch (error) {
console.error('[Router Init Error] Core data fetch failed:', error); console.error('[Router Init Error] Core data fetch failed:', error);
} finally {
setIsLoaded(true);
} }
}, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]); }, [defaultRoute, syncKey, syncRoute, visiblePagesKey, pageOrderKey]);
@@ -187,7 +191,7 @@ export function RouterProvider({
}, [loadInitialData]); }, [loadInitialData]);
useEffect(() => { useEffect(() => {
if (isLoaded && syncRoute) { if (isLoaded && canPersistRef.current && syncRoute) {
void storageUtil.set(syncKey, currentPage as PageType).catch(console.error); void storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
try { try {
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage)); localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
@@ -198,7 +202,7 @@ export function RouterProvider({
}, [currentPage, isLoaded, syncRoute, syncKey]); }, [currentPage, isLoaded, syncRoute, syncKey]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded && canPersistRef.current) {
void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error); void storageUtil.set(visiblePagesKey, visiblePages).catch(console.error);
try { try {
localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages)); localStorage.setItem(`snapshot/${visiblePagesKey}`, JSON.stringify(visiblePages));
@@ -209,7 +213,7 @@ export function RouterProvider({
}, [visiblePages, isLoaded, visiblePagesKey]); }, [visiblePages, isLoaded, visiblePagesKey]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded && canPersistRef.current) {
void storageUtil.set(pageOrderKey, pageOrder).catch(console.error); void storageUtil.set(pageOrderKey, pageOrder).catch(console.error);
try { try {
localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder)); localStorage.setItem(`snapshot/${pageOrderKey}`, JSON.stringify(pageOrder));
@@ -220,7 +224,7 @@ export function RouterProvider({
}, [pageOrder, isLoaded, pageOrderKey]); }, [pageOrder, isLoaded, pageOrderKey]);
useEffect(() => { useEffect(() => {
if (isLoaded) { if (isLoaded && canPersistRef.current) {
void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error); void storageUtil.set('app/recentlyUsedTools', recentlyUsedTools).catch(console.error);
try { try {
localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools)); localStorage.setItem('snapshot/app/recentlyUsedTools', JSON.stringify(recentlyUsedTools));
@@ -283,6 +287,8 @@ export function RouterProvider({
}, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]); }, [syncRoute, syncKey, visiblePagesKey, pageOrderKey]);
const navigateTo = (page: PageType) => { const navigateTo = (page: PageType) => {
hasUserNavigatedRef.current = true;
canPersistRef.current = true;
setCurrentPage(page); setCurrentPage(page);
setRecentlyUsedTools((prev) => { setRecentlyUsedTools((prev) => {
const filtered = prev.filter((p) => p !== page); const filtered = prev.filter((p) => p !== page);
@@ -291,6 +297,8 @@ export function RouterProvider({
}; };
const goHome = () => { const goHome = () => {
hasUserNavigatedRef.current = true;
canPersistRef.current = true;
setCurrentPage('dashboard'); setCurrentPage('dashboard');
}; };
@@ -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 () => { it('组件卸载时不应设置 isLoaded 状态(竞态条件防护)', async () => {
let resolveStorage: (value: unknown) => void; let resolveStorage: (value: unknown) => void;
const storagePromise = new Promise((resolve) => { 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` - `JsonToolsPageMode` — JSON 工具子模式(`diff` | `format` | `yaml` | `toml` | `minify`
- `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image` - `Base64ConverterPageMode` — Base64 子模式(`text` | `file` | `image`
- `Base64ConvertDirection` — 编解码方向(`encode` | `decode` - `Base64ConvertDirection` — 编解码方向(`encode` | `decode`
- `MarkdownToHtmlPreviewMode` — Markdown 预览模式(`split` | `preview` | `html`
- `HtmlToMarkdownPreviewMode` — HTML 预览模式(`split` | `preview` | `markdown`
**存储 Schema** **存储 Schema**
@@ -34,6 +32,16 @@
`qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。 `qrious` 库的类型声明,定义 QR 码生成选项和 `QRious` 类。
### testDataGenerator.ts
测试数据生成器共享类型,包含:
- `FieldConfig` — 字段配置(字段名、生成器、参数、必填、空值率、唯一性)
- `DataRule` — 可保存/导入/导出的字段规则
- `GeneratorDefinition` / `GeneratorParam` — 内置生成器定义和参数 Schema
- `GenerateResult` / `GenerateProgress` / `WorkerMessage` — Worker 生成结果、进度和消息协议
- `ExportFile` — JSON/CSV 导出文件描述
## 修改 StorageSchema 的注意事项 ## 修改 StorageSchema 的注意事项
修改 `StorageSchema` 时,必须: 修改 `StorageSchema` 时,必须:
+5 -15
View File
@@ -156,29 +156,19 @@ export interface GenerateProgress {
} }
/** /**
* Worker 请求消息类型 * Worker 消息类型
*/ */
export type WorkerRequestMessage = export type WorkerMessage =
| { type: 'start'; payload: WorkerStartPayload } | { type: 'start'; payload: WorkerStartPayload }
| { type: 'progress'; payload: GenerateProgress }
| { type: 'complete'; payload: GenerateResult }
| { type: 'error'; payload: { error: string } }
| { type: 'cancel' }; | { 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 启动参数 * Worker 启动参数
*/ */
export interface WorkerStartPayload { export interface WorkerStartPayload {
/** 生成任务 ID,用于忽略过期 Worker 响应 */
generationId: number;
/** 字段配置列表 */ /** 字段配置列表 */
fields: FieldConfig[]; fields: FieldConfig[];
/** 生成数量 */ /** 生成数量 */
+19 -6
View File
@@ -5,8 +5,11 @@
## 工具函数 ## 工具函数
| 文件 | 用途 | | 文件 | 用途 |
| -------------------- | --------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------------------------------- |
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 | | `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 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 | | `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) | | `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
| `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 | | `messages.ts` | 扩展消息通信:基于 `@webext-core/messaging`,定义 `MessageAction` 枚举和 `ProtocolMap` 类型安全映射 |
@@ -16,27 +19,37 @@
| `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify | | `jsonFormatter.ts` | JSON 格式化/压缩:支持缩进、按键排序、minify |
| `jsonToYaml.ts` | JSON→YAML 转换 | | `jsonToYaml.ts` | JSON→YAML 转换 |
| `jsonToToml.ts` | JSON→TOML 转换 | | `jsonToToml.ts` | JSON→TOML 转换 |
| `markdownToHtml.ts` | Markdown→HTML 转换:基于 `marked` 库,支持 GFM 和换行转换 |
| `htmlToMarkdown.ts` | HTML→Markdown 转换:基于 DOMParser 解析 |
| `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 | | `qrCodeParser.ts` | 二维码解析:基于 `qr-scanner` 库从文件中解析二维码 |
| `storageCleaner.ts` | 存储清理:获取当前标签页、检测受限 URL、计算 Cookie/Storage 大小、清理操作 | | `storageCleaner.ts` | 存储清理:获取当前标签页、计算 Cookie/Storage 大小、清理操作 |
| `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 | | `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 |
| `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB | | `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB |
| `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 | | `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 |
| `ruleStorage.ts` | 测试数据生成器规则存储:基于 `localStorage` 的 CRUD、搜索、导入/导出和数量限制 |
| `dataExporter.ts` | 测试数据导出:JSON/CSV 转换、文件下载和复制到剪贴板 |
| `rightClickInjection.ts` | 右键恢复注入脚本:在页面上下文恢复 contextmenu/copy/paste 等事件默认行为 |
## 自定义 Hooks ## 自定义 Hooks
| 文件 | 用途 | | 文件 | 用途 |
| ----------------------- | -------------------------------------------------------------------------------------------------------------- | | ----------------------- | -------------------------------------------------------------------------------------------------------------- |
| `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 | | `useStorageState.ts` | Chrome Storage 状态 Hook:类似 `useState`,值自动同步到 `chrome.storage`,使用 `localStorage` 快照消除首屏闪烁 |
| `useLazyTranslation.ts` | 懒加载翻译 Hook:按需动态导入 i18n 命名空间,支持预加载和缓存 |
| `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 | | `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 |
| `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 | | `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 |
### useStorageState 初始化防覆盖
`useStorageState` 在挂载时从 storage 异步加载。写入 storage 需满足以下任一条件:
- **`loadSucceededRef`**storage 读取成功
- **`userModifiedRef`**:用户通过 setter 主动修改过值
若 storage 读取失败且用户未修改,不会将默认值写回 storage,避免静默覆盖已有数据。
## 使用约定 ## 使用约定
- 工具函数使用**命名导出**`export function xxx()` - 工具函数使用**命名导出**`export function xxx()`
- 工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象 - 工具层不直接展示 Toast;可恢复错误返回可判断结果,需要抛出的解析/转换错误由页面 Hook 或 UI 层捕获
- Hook 使用 `use` 前缀命名,定义返回值接口类型 - Hook 使用 `use` 前缀命名,定义返回值接口类型
- 存储操作使用 `chromeStorage.ts``storageUtil` 封装,不要直接调用 `chrome.storage` - 存储操作使用 `chromeStorage.ts``storageUtil` 封装,不要直接调用 `chrome.storage`
- 消息通信使用 `messages.ts``sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage` - 消息通信使用 `messages.ts``sendMessage`/`onMessage`,不要使用原生 `chrome.runtime.sendMessage`
- 需要首屏快照的新持久化状态,优先复用 `syncSnapshot.ts` 或参考 `themeSnapshot.ts` 模式
+1 -31
View File
@@ -1,37 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { getCurrentTab, openExtensionPage } from '@/utils/chromeTabs'; import { openExtensionPage } from '@/utils/chromeTabs';
describe('chromeTabs', () => { describe('chromeTabs', () => {
describe('getCurrentTab', () => {
it('应优先使用 lastFocusedWindow 查询当前标签页', async () => {
const mockTab = { id: 1, url: 'https://example.com' };
(browser.tabs.query as ReturnType<typeof vi.fn>).mockResolvedValueOnce([mockTab]);
const tab = await getCurrentTab();
expect(tab).toEqual(mockTab);
expect(browser.tabs.query).toHaveBeenCalledWith({
active: true,
lastFocusedWindow: true,
});
});
it('当 lastFocusedWindow 无结果时应回退到 currentWindow', async () => {
const fallbackTab = { id: 2, url: 'https://fallback.com' };
(browser.tabs.query as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([fallbackTab]);
const tab = await getCurrentTab();
expect(tab).toEqual(fallbackTab);
expect(browser.tabs.query).toHaveBeenLastCalledWith({
active: true,
currentWindow: true,
});
});
});
describe('openExtensionPage', () => { describe('openExtensionPage', () => {
it('应该在新标签页中打开扩展页面', async () => { it('应该在新标签页中打开扩展页面', async () => {
await openExtensionPage('popup.html'); await openExtensionPage('popup.html');
-83
View File
@@ -1,83 +0,0 @@
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();
});
it('importRules 应能恢复导出的规则备份', () => {
const saved = ruleStorage.save({
name: 'Backup Rule',
fields: [mockField],
});
expect(saved).not.toBeNull();
const exported = ruleStorage.exportRules();
ruleStorage.clear();
const result = ruleStorage.importRules(exported);
expect(result.success).toBe(1);
expect(result.failed).toBe(0);
expect(ruleStorage.getAll()).toHaveLength(1);
expect(ruleStorage.getAll()[0].name).toBe('Backup Rule');
});
});
@@ -166,4 +166,18 @@ describe('useStorageState', () => {
expect(result.current[2]).toBe(false); 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();
});
});
}); });
-24
View File
@@ -1,31 +1,7 @@
import { browser } from 'wxt/browser';
/** /**
* Chrome 标签页相关工具函数 * Chrome 标签页相关工具函数
*/ */
/**
* 获取用户当前正在浏览的标签页。
* popup 中 `currentWindow` 指向弹窗自身,需优先使用 `lastFocusedWindow`。
*/
export async function getCurrentTab() {
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab) {
return tab;
}
const [fallbackTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
return fallbackTab;
}
/** /**
* 在新标签页中打开扩展页面 * 在新标签页中打开扩展页面
* @param page - 扩展页面路径(如 'popup.html' * @param page - 扩展页面路径(如 'popup.html'
+1 -2
View File
@@ -1,5 +1,4 @@
import { defineExtensionMessaging } from '@webext-core/messaging'; import { defineExtensionMessaging } from '@webext-core/messaging';
import { getCurrentTab } from '@/utils/chromeTabs';
export enum MessageAction { export enum MessageAction {
SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged', SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged',
@@ -39,7 +38,7 @@ export async function sendMessageToContent<K extends keyof ProtocolMap>(
: [data: Parameters<ProtocolMap[K]>[0]] : [data: Parameters<ProtocolMap[K]>[0]]
): Promise<ReturnType<ProtocolMap[K]>> { ): Promise<ReturnType<ProtocolMap[K]>> {
try { try {
const tab = await getCurrentTab(); const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) { if (!tab?.id) {
console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`); console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`);
return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>; return { success: false, message: '无法获取当前标签页' } as ReturnType<ProtocolMap[K]>;
+8 -24
View File
@@ -78,9 +78,7 @@ export function save(
useCount: 0, useCount: 0,
}; };
rules.unshift(newRule); rules.unshift(newRule);
if (!setAll(rules)) { setAll(rules);
return null;
}
return newRule; return newRule;
} }
@@ -97,9 +95,7 @@ export function save(
updatedAt: now, updatedAt: now,
}; };
rules[index] = updatedRule; rules[index] = updatedRule;
if (!setAll(rules)) { setAll(rules);
return null;
}
return updatedRule; return updatedRule;
} }
@@ -121,9 +117,7 @@ export function update(id: string, updates: Partial<DataRule>): DataRule | null
updatedAt: Date.now(), updatedAt: Date.now(),
}; };
rules[index] = updatedRule; rules[index] = updatedRule;
if (!setAll(rules)) { setAll(rules);
return null;
}
return updatedRule; return updatedRule;
} }
@@ -138,7 +132,8 @@ export function deleteRule(id: string): boolean {
return false; return false;
} }
rules.splice(index, 1); rules.splice(index, 1);
return setAll(rules); setAll(rules);
return true;
} }
/** /**
@@ -170,9 +165,7 @@ export function duplicate(id: string, copySuffix = '(副本)'): DataRule | n
const rules = getAll(); const rules = getAll();
rules.unshift(newRule); rules.unshift(newRule);
if (!setAll(rules)) { setAll(rules);
return null;
}
return newRule; return newRule;
} }
@@ -248,14 +241,7 @@ export function importRules(jsonString: string): {
continue; continue;
} }
const ruleItem = item as DataRule; const saved = save(item as Omit<DataRule, 'id' | 'createdAt' | 'updatedAt' | 'useCount'>);
const existsLocally = Boolean(ruleItem.id && getById(ruleItem.id));
const saved = save({
name: ruleItem.name,
description: ruleItem.description,
fields: ruleItem.fields,
...(existsLocally ? { id: ruleItem.id } : {}),
});
if (saved) { if (saved) {
result.success++; result.success++;
} else { } else {
@@ -281,13 +267,11 @@ export function clear(): void {
/** /**
* 保存所有规则到存储 * 保存所有规则到存储
*/ */
function setAll(rules: DataRule[]): boolean { function setAll(rules: DataRule[]): void {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); localStorage.setItem(STORAGE_KEY, JSON.stringify(rules));
return true;
} catch (error) { } catch (error) {
console.error('[ruleStorage] 保存规则失败:', error); console.error('[ruleStorage] 保存规则失败:', error);
return false;
} }
} }
+23
View File
@@ -11,6 +11,29 @@ import { browser } from 'wxt/browser';
type CleanScriptResult = IndexedDBCleanResult; type CleanScriptResult = IndexedDBCleanResult;
export async function getCurrentTab() {
// For popup pages, we need to get the active tab from the browser window that triggered the popup.
// We should ONLY care about the currently active tab in the last focused window.
// If it's a restricted URL, we return it anyway and let the caller handle the error display.
const [tab] = await browser.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab) {
return tab;
}
// Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios)
const [fallbackTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
return fallbackTab;
}
export async function getCookieSize(url: string): Promise<number> { export async function getCookieSize(url: string): Promise<number> {
try { try {
const cookies = await browser.cookies.getAll({ url }); const cookies = await browser.cookies.getAll({ url });
+21 -4
View File
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from 'react';
import { storageUtil } from '@/utils/chromeStorage'; import { storageUtil } from '@/utils/chromeStorage';
import { getSyncSnapshot } from '@/utils/syncSnapshot'; import { getSyncSnapshot } from '@/utils/syncSnapshot';
import type { StorageSchema } from '@/types/storage'; import type { StorageSchema } from '@/types/storage';
@@ -8,11 +15,18 @@ export const useStorageState = <K extends keyof StorageSchema>(
defaultValue: StorageSchema[K], defaultValue: StorageSchema[K],
validator?: (val: unknown) => val is StorageSchema[K], validator?: (val: unknown) => val is StorageSchema[K],
) => { ) => {
const [value, setValue] = useState<StorageSchema[K]>(() => const [value, setValueInternal] = useState<StorageSchema[K]>(() =>
getSyncSnapshot(key as string, defaultValue, validator), getSyncSnapshot(key as string, defaultValue, validator),
); );
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const hasLoadedFromStorage = useRef(false); const hasLoadedFromStorage = useRef(false);
const loadSucceededRef = useRef(false);
const userModifiedRef = useRef(false);
const setValue = useCallback<Dispatch<SetStateAction<StorageSchema[K]>>>((next) => {
userModifiedRef.current = true;
setValueInternal(next);
}, []);
// Only load from storage once on mount // Only load from storage once on mount
useEffect(() => { useEffect(() => {
@@ -24,15 +38,17 @@ export const useStorageState = <K extends keyof StorageSchema>(
try { try {
const savedValue = await storageUtil.get(key, defaultValue); const savedValue = await storageUtil.get(key, defaultValue);
if (cancelled) return; if (cancelled) return;
loadSucceededRef.current = true;
if (savedValue !== undefined) { if (savedValue !== undefined) {
if (validator) { if (validator) {
setValue(validator(savedValue) ? savedValue : defaultValue); setValueInternal(validator(savedValue) ? savedValue : defaultValue);
} else { } else {
setValue(savedValue); setValueInternal(savedValue);
} }
} }
} catch (error) { } catch (error) {
console.error(`加载状态失败 (${key}):`, error); console.error(`加载状态失败 (${key}):`, error);
loadSucceededRef.current = false;
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setIsInitialized(true); setIsInitialized(true);
@@ -51,6 +67,7 @@ export const useStorageState = <K extends keyof StorageSchema>(
// Save to storage and localStorage snapshot when value changes (after initial load) // Save to storage and localStorage snapshot when value changes (after initial load)
useEffect(() => { useEffect(() => {
if (!isInitialized) return; if (!isInitialized) return;
if (!loadSucceededRef.current && !userModifiedRef.current) return;
const saveState = async () => { const saveState = async () => {
try { try {
+5 -28
View File
@@ -6,14 +6,11 @@
import { getGeneratorById } from '@/lib/generators'; import { getGeneratorById } from '@/lib/generators';
import type { import type {
FieldConfig, FieldConfig,
WorkerRequestMessage, WorkerMessage,
GenerateResult, GenerateResult,
GenerateProgress, GenerateProgress,
} from '@/types/testDataGenerator'; } from '@/types/testDataGenerator';
/** 每生成 N 行让出一次事件循环,以便处理 cancel 消息 */
const YIELD_EVERY = 100;
// 生成结果缓存 // 生成结果缓存
let generatedData: Record<string, unknown>[] = []; let generatedData: Record<string, unknown>[] = [];
let isCancelled = false; let isCancelled = false;
@@ -21,7 +18,7 @@ let isCancelled = false;
/** /**
* Worker 消息处理器 * Worker 消息处理器
*/ */
self.onmessage = async (e: MessageEvent<WorkerRequestMessage>) => { self.onmessage = async (e: MessageEvent<WorkerMessage>) => {
const data = e.data; const data = e.data;
const { type } = data; const { type } = data;
@@ -40,12 +37,11 @@ self.onmessage = async (e: MessageEvent<WorkerRequestMessage>) => {
* 处理开始生成消息 * 处理开始生成消息
*/ */
async function handleStart(payload: { async function handleStart(payload: {
generationId: number;
fields: FieldConfig[]; fields: FieldConfig[];
count: number; count: number;
csvMode: boolean; csvMode: boolean;
}): Promise<void> { }): Promise<void> {
const { generationId, fields, count } = payload; const { fields, count } = payload;
generatedData = []; generatedData = [];
try { try {
@@ -55,7 +51,6 @@ async function handleStart(payload: {
if (!generator) { if (!generator) {
self.postMessage({ self.postMessage({
type: 'error', type: 'error',
generationId,
payload: { error: `生成器 "${field.generatorId}" 不存在` }, payload: { error: `生成器 "${field.generatorId}" 不存在` },
}); });
return; return;
@@ -70,7 +65,6 @@ async function handleStart(payload: {
if (isCancelled) { if (isCancelled) {
self.postMessage({ self.postMessage({
type: 'complete', type: 'complete',
generationId,
payload: { payload: {
success: false, success: false,
error: '生成已取消', error: '生成已取消',
@@ -142,23 +136,7 @@ async function handleStart(payload: {
total: count, total: count,
estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)), estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)),
}; };
self.postMessage({ type: 'progress', generationId, payload: progress }); self.postMessage({ type: 'progress', payload: progress });
}
// 定期让出事件循环,使 cancel 消息能被处理
if ((i + 1) % YIELD_EVERY === 0) {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (isCancelled) {
self.postMessage({
type: 'complete',
generationId,
payload: {
success: false,
error: '生成已取消',
},
});
return;
}
} }
} }
@@ -177,11 +155,10 @@ async function handleStart(payload: {
}, },
}; };
self.postMessage({ type: 'complete', generationId, payload: result }); self.postMessage({ type: 'complete', payload: result });
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({
type: 'error', type: 'error',
generationId,
payload: { error: `生成失败: ${error}` }, payload: { error: `生成失败: ${error}` },
}); });
} }