Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f09d438f0f | |||
| 0545910b16 | |||
| 1948ba67a7 | |||
| 1041b3ece1 |
+88
-84
@@ -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.ts — i18n 懒加载
|
||||
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. 基础 state(useState)
|
||||
// 1. 基础 state(useState)
|
||||
const [mode, setMode] = useState<Mode>('default');
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
// 3. 持久化 state(useStorageState)
|
||||
// 2. 持久化 state(useStorageState)
|
||||
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` Hook(index.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/` | 静态资源(图标等) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`)
|
||||
- Prettier:100 字符宽、单引号、尾逗号 all、LF 换行
|
||||
- ESLint 使用 `typescript-eslint` 的 `projectService: true`
|
||||
- 导出模式:页面组件 default export,工具函数/Hook 命名导出,UI 组件 forwardRef + 命名导出
|
||||
|
||||
### 测试
|
||||
|
||||
- 环境:jsdom
|
||||
- 全局变量:`vitest/globals`(describe、it、expect 等无需导入)
|
||||
- Setup 文件:`vitest.setup.ts` 自动 mock 以下内容:
|
||||
- `chrome.*` / `browser.*` API(storage、tabs、runtime、cookies 等)
|
||||
- `react-i18next`(返回 key 作为翻译)
|
||||
- `@/utils/useLazyTranslation`(返回 `ns:key` 格式)
|
||||
- `window.matchMedia`
|
||||
- 测试文件命名:`__tests__/*.test.{ts,tsx}` 或 `*.test.{ts,tsx}`
|
||||
- 使用 `vi.mock()` 进行模块级 mock;避免重复 mock `vitest.setup.ts` 中已有的内容
|
||||
- 测试工具:`@testing-library/react` + `@testing-library/user-event`
|
||||
|
||||
### 国际化(i18n)
|
||||
|
||||
- 命名空间:`common`(默认)、`features`
|
||||
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`)
|
||||
- 语言:`zh`(默认)、`en`
|
||||
- 翻译文件:`i18n/locales/{zh,en}/{common,features}.json` + 各功能独立 JSON 文件
|
||||
- 使用 `useLazyTranslation` Hook 加载功能专属翻译
|
||||
- 回退策略:缺失的翻译键回退到 `zh`,若仍缺失则返回占位格式 `namespace:key`
|
||||
|
||||
### WXT 生成文件
|
||||
|
||||
- `.wxt/` 目录由 `postinstall`(`wxt prepare`)自动生成,包含 TypeScript 类型声明和扩展 tsconfig
|
||||
- 生产构建输出到 `.output/` 目录
|
||||
- `tsconfig.json` 继承自 `./.wxt/tsconfig.json`
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、Markdown、测试数据生成器等测试效率工具。
|
||||
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。
|
||||
|
||||
## 核心命令
|
||||
|
||||
@@ -16,11 +16,33 @@ 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`
|
||||
|
||||
### CI(GitHub Actions)
|
||||
|
||||
1. `setup` — 安装依赖
|
||||
2. 并行 `lint`、`typecheck`(含 `wxt prepare`)、`test`
|
||||
3. `build` — Chrome + Firefox 矩阵构建(仅当步骤 2 全部通过)
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -28,6 +50,7 @@ npm run test:coverage # 带覆盖率的测试
|
||||
src/ # 源代码根目录
|
||||
config/features.tsx # 功能定义(路由 + 元数据的单一事实来源)
|
||||
entrypoints/ # 扩展入口点 (popup/, sidepanel/, background.ts, content.ts)
|
||||
layout/ # 应用壳层布局(TopBar 导航、搜索、主题切换)
|
||||
pages/ # 功能页面组件 (懒加载)
|
||||
components/ # 可复用 UI 组件
|
||||
components/ui/ # shadcn/ui 基础组件 (button, dialog, select 等)
|
||||
@@ -35,11 +58,22 @@ src/ # 源代码根目录
|
||||
hooks/ # 自定义 React Hooks
|
||||
utils/ # 工具函数与服务抽象
|
||||
types/ # TypeScript 类型声明
|
||||
lib/ # 通用工具函数(cn、utils)
|
||||
lib/ # 通用工具函数(cn、utils)及数据生成器定义
|
||||
workers/ # Web Worker(数据生成等耗时任务)
|
||||
public/ # 静态资源(图标、_locales 等)
|
||||
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 通知
|
||||
|
||||
@@ -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`: 管理后台定时任务(如清理后自动刷新).
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
|
||||
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
|
||||
- 图标格式推荐使用 PNG,确保透明背景
|
||||
- UI 文案不在此目录维护,见 `src/config/features.tsx` 与各页面组件
|
||||
|
||||
@@ -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` 的不同配置实现独立路由
|
||||
+32
-2
@@ -4,9 +4,10 @@
|
||||
|
||||
## 文件说明
|
||||
|
||||
| 文件 | 用途 |
|
||||
| ---------- | ------------------------------------ |
|
||||
| 文件/目录 | 用途 |
|
||||
| ------------- | -------------------------------------- |
|
||||
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
|
||||
| `generators/` | 测试数据生成器内置生成器库和分类注册表 |
|
||||
|
||||
## cn()
|
||||
|
||||
@@ -24,3 +25,32 @@ 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` | 基础类型生成器(整数、浮点数、布尔值、字符串) |
|
||||
| `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,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. 添加对应的单元测试
|
||||
@@ -103,4 +103,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('当前标签页已切换,请等待数据刷新后再清理');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function Index() {
|
||||
sizes,
|
||||
reloadAfterClean,
|
||||
loading,
|
||||
isRefreshingSizes,
|
||||
result,
|
||||
showConfirm,
|
||||
setShowConfirm,
|
||||
@@ -26,7 +27,7 @@ export default function Index() {
|
||||
handleClean,
|
||||
} = useStorageCleaner();
|
||||
|
||||
const isButtonDisabled = !(someSelected || allSelected) || loading;
|
||||
const isButtonDisabled = !(someSelected || allSelected) || loading || isRefreshingSizes;
|
||||
|
||||
if (isInitializing) {
|
||||
return (
|
||||
|
||||
@@ -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);
|
||||
@@ -117,6 +120,7 @@ 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 +163,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 +172,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
} finally {
|
||||
if (currentRequestId === requestIdRef.current) {
|
||||
setIsInitializing(false);
|
||||
setIsRefreshingSizes(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -256,6 +262,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 {
|
||||
@@ -297,6 +310,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
|
||||
sizes,
|
||||
reloadAfterClean,
|
||||
loading,
|
||||
isRefreshingSizes,
|
||||
result,
|
||||
showConfirm,
|
||||
setShowConfirm,
|
||||
|
||||
+15
-5
@@ -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`
|
||||
|
||||
@@ -105,6 +105,8 @@ export function RouterProvider({
|
||||
);
|
||||
|
||||
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 savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []);
|
||||
|
||||
if (isValidPage(savedRoute) && syncRoute) {
|
||||
if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) {
|
||||
setCurrentPage(savedRoute);
|
||||
}
|
||||
if (isValidPageList(savedVisiblePages)) {
|
||||
@@ -131,9 +133,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]);
|
||||
|
||||
@@ -187,7 +191,7 @@ export function RouterProvider({
|
||||
}, [loadInitialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoaded && syncRoute) {
|
||||
if (isLoaded && canPersistRef.current && syncRoute) {
|
||||
void storageUtil.set(syncKey, currentPage as PageType).catch(console.error);
|
||||
try {
|
||||
localStorage.setItem(`snapshot/${syncKey}`, JSON.stringify(currentPage));
|
||||
@@ -198,7 +202,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 +213,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 +224,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 +287,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 +297,8 @@ export function RouterProvider({
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
hasUserNavigatedRef.current = true;
|
||||
canPersistRef.current = true;
|
||||
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 () => {
|
||||
let resolveStorage: (value: unknown) => void;
|
||||
const storagePromise = new Promise((resolve) => {
|
||||
|
||||
+11
-3
@@ -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` 时,必须:
|
||||
|
||||
+19
-6
@@ -5,8 +5,11 @@
|
||||
## 工具函数
|
||||
|
||||
| 文件 | 用途 |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `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` 类型安全映射 |
|
||||
@@ -16,27 +19,37 @@
|
||||
| `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 大小、清理操作 |
|
||||
| `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` 模式
|
||||
|
||||
@@ -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,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 { getSyncSnapshot } from '@/utils/syncSnapshot';
|
||||
import type { StorageSchema } from '@/types/storage';
|
||||
@@ -8,11 +15,18 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
||||
defaultValue: 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),
|
||||
);
|
||||
const [isInitialized, setIsInitialized] = useState(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
|
||||
useEffect(() => {
|
||||
@@ -24,15 +38,17 @@ export const useStorageState = <K extends keyof StorageSchema>(
|
||||
try {
|
||||
const savedValue = await storageUtil.get(key, defaultValue);
|
||||
if (cancelled) return;
|
||||
loadSucceededRef.current = true;
|
||||
if (savedValue !== undefined) {
|
||||
if (validator) {
|
||||
setValue(validator(savedValue) ? savedValue : defaultValue);
|
||||
setValueInternal(validator(savedValue) ? savedValue : defaultValue);
|
||||
} else {
|
||||
setValue(savedValue);
|
||||
setValueInternal(savedValue);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载状态失败 (${key}):`, error);
|
||||
loadSucceededRef.current = false;
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
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)
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
if (!loadSucceededRef.current && !userModifiedRef.current) return;
|
||||
|
||||
const saveState = async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user