Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent 6776a89003 fix: 修复路由初始化竞态、存储状态静默覆盖与存储清理误删 2026-06-26 09:12:58 +00:00
158 changed files with 1692 additions and 1988 deletions
+79 -83
View File
@@ -265,11 +265,12 @@ const visibleSet = useMemo(() => new Set<string>(visiblePages), [visiblePages]);
2. 第三方库(图标、UI 库等) 2. 第三方库(图标、UI 库等)
3. 业务 Provider / Context 3. 业务 Provider / Context
4. 配置 / 存储 4. 配置 / 存储
5. 本地页面组件 5. i18n
6. UI 组件 6. 本地页面组件
7. 工具函数 / Hook 7. UI 组件
8. 类型 8. 工具函数 / Hook
9. 常量 9. 类型
10. 常量
```typescript ```typescript
// 1. React 核心 // 1. React 核心
@@ -283,16 +284,19 @@ 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. 本地组件 // 5. i18n
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';
// 6. UI 组件 // 7. UI 组件
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
// 7. 工具函数 / Hook // 8. 工具函数 / Hook
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useStorageState } from '@/utils/useStorageState'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
// 8. 类型 // 9. 类型
import type { PageType, StorageSchema } from '@/types/storage'; import type { PageType, StorageSchema } from '@/types/storage';
``` ```
@@ -397,22 +401,22 @@ className={cn(
## 5. 错误处理 ## 5. 错误处理
### 5.1 工具函数:可恢复错误返回可判断结果 ### 5.1 工具函数:结果对象模式
可恢复的解析/校验错误应返回可判断的结果,避免工具层直接弹 Toast。确需保留底层异常的函数 工具函数**不抛异常**,返回包含 `hasError``error` 字段的结果对象:
(如 `formatJson` / `minifyJson`)必须在页面 Hook 或 UI 层捕获并转换为用户提示:
```typescript ```typescript
// ✅ 可恢复校验返回错误消息,调用方据此展示 UI // ✅ 结果对象模式
export function validateJson(text: string): string | null { export function markdownToHtml(markdown: string): MarkdownToHtmlResult {
if (!text.trim()) {
return null;
}
try { try {
JSON.parse(text.trim()); ...
return null; return { html, originalLength, htmlLength, hasError: false };
} catch (e) { } catch (error) {
return e instanceof SyntaxError ? e.message : 'Invalid JSON'; return {
html: '', ...
hasError: true,
error: error instanceof Error ? error.message : 'Markdown 解析失败',
};
} }
} }
``` ```
@@ -556,7 +560,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、matchMedia 等) - **避免重复 mock `vitest.setup.ts` 中已有的内容**chrome API、i18n、matchMedia 等)
```typescript ```typescript
// ✅ 模块级 Mock // ✅ 模块级 Mock
@@ -654,7 +658,7 @@ export function useTimestampConverter(): UseTimestampConverterReturn { ... }
``` ```
src/utils/useStorageState.ts — Chrome Storage 状态持久化 src/utils/useStorageState.ts — Chrome Storage 状态持久化
src/utils/syncSnapshot.ts localStorage 快照读取 src/utils/useLazyTranslation.tsi18n 懒加载
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
@@ -663,50 +667,47 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
--- ---
## 9. UI 文案规范 ## 9. 国际化规范
项目已移除 `chrome.i18n`,所有 UI 文案直接在代码中使用中文。 ### 9.1 翻译键格式
### 9.1 功能元数据 - 命名空间:`common`(默认)、`features`
- 翻译键格式:`namespace:key`(如 `features:timestamp.title`
- 语言:`zh`(默认)、`en`
功能名称与描述在 `config/features.tsx``FEATURES` 数组中定义: ### 9.2 翻译文件结构
```typescript ```
{ i18n/locales/{zh,en}/common.json — 全局通用翻译
key: 'timestamp', i18n/locales/{zh,en}/features.json — 功能模块标题和描述
label: '时间戳转换', i18n/locales/{zh,en}/{功能名}.json — 各功能独立翻译
description: '日期与时间戳互转',
defaultVisible: true,
component: TimestampPage,
}
``` ```
Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description` ### 9.3 使用方式
### 9.2 页面与组件文案
- 页面标题、按钮、提示信息等直接在 JSX 或 `constants.ts` 中写中文
- 错误消息可在 Hook 中定义,或使用常量映射
- Toast 通知使用 `sonner``toast()`,文案写在调用处或常量中
```typescript ```typescript
// ✅ 页面组件 — 直接写中文 // ✅ 页面组件 — 使用 useLazyTranslation
import { useLazyTranslation } from '@/utils/useLazyTranslation';
export default function Index() { export default function Index() {
return <h1 className="font-bold text-sm"></h1>; const { t } = useLazyTranslation('timestamp');
return <h1>{t('timestamp:title')}</h1>;
} }
// ✅ 常量文件 — 可复用文案 // ✅ 全局组件 — 使用 useTranslation
export const ERROR_MESSAGES = { import { useTranslation } from 'react-i18next';
invalidInput: '输入格式无效',
conversionFailed: '转换失败', export function TopBar() {
} as const; const { t } = useTranslation(['common', 'features']);
return <span>{t('common:settings')}</span>;
}
``` ```
### 9.3 添加新功能文案 ### 9.4 添加新翻译
1.`config/features.tsx` 填写 `label``description` 1.`i18n/locales/{zh,en}/features.json` 添加功能标题和描述
2. 在页面组件、`constants.ts` 或 Hook 中编写 UI 文案 2. 创建 `i18n/locales/{zh,en}/{功能名}.json` 添加功能专属翻译
3. 扩展名称与描述在 `wxt.config.ts``manifest`维护 3. `utils/useLazyTranslation.ts``localeModules`注册新命名空间
--- ---
@@ -759,18 +760,6 @@ 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. 页面开发规范
@@ -853,21 +842,24 @@ export default function DashboardPage() { ... }
#### 组件职责 #### 组件职责
`index.tsx` 只负责件事: `index.tsx` 只负责件事:
1. **调用业务 Hook** 获取状态和操作方法 1. **获取翻译函数**`useI18n`
2. **渲染 UI 布局**(纯展示,无业务逻辑) 2. **调用业务 Hook** 获取状态和操作方法
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>
); );
} }
@@ -911,22 +903,25 @@ export function useTimestampConverter(): UseTimestampConverterReturn {
```typescript ```typescript
export function useFeatureName(): UseFeatureNameReturn { export function useFeatureName(): UseFeatureNameReturn {
// 1. 基础 stateuseState // 1. i18n
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('');
// 2. 持久化 stateuseStorageState // 3. 持久化 stateuseStorageState
const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode); const [pageMode, setPageMode] = useStorageState('feature/pageMode', 'default', isValidMode);
// 3. 衍生数据(useMemo)— 响应式计算管线 // 4. 衍生数据(useMemo)— 响应式计算管线
const result = useMemo(() => { const result = useMemo(() => {
// 自动计算,无需手动点击"转换"按钮 // 自动计算,无需手动点击"转换"按钮
}, [input, mode]); }, [input, mode]);
// 4. 事件处理(useCallback // 5. 事件处理(useCallback
const handleAction = useCallback(() => { ... }, [deps]); const handleAction = useCallback(() => { ... }, [deps]);
// 5. 副作用(useEffect)— 防抖、初始化、清理 // 6. 副作用(useEffect)— 防抖、初始化、清理
useEffect(() => { ... }, [deps]); useEffect(() => { ... }, [deps]);
// 7. 右键菜单数据(页面需要时) // 7. 右键菜单数据(页面需要时)
@@ -996,7 +991,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) => {
// 文案直接写中文或使用 constants const { t } = useI18n('featureName');
// ... // ...
}, },
); );
@@ -1006,7 +1001,7 @@ export default ResultView;
#### 子组件内可以使用 Hook #### 子组件内可以使用 Hook
子组件可以独立调用 `useRouter``useThemeMode``toast` 等全局 Hook/API,**不需要**通过 props 从父组件传递。 子组件可以独立调用 `useI18n``useSnackbar` 等全局 Hook**不需要**通过 props 从父组件传递翻译函数或 toast 方法
--- ---
@@ -1043,6 +1038,7 @@ 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 (
@@ -1050,8 +1046,8 @@ export default function Index() {
<SwitchButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
options={[ options={[
{ value: 'modeA', label: '模式 A' }, { value: 'modeA', label: t('feature:modeA') },
{ value: 'modeB', label: '模式 B' }, { value: 'modeB', label: t('feature:modeB') },
]} ]}
onChange={(v: PageMode) => setPageMode(v)} onChange={(v: PageMode) => setPageMode(v)}
size="small" size="small"
@@ -1082,12 +1078,12 @@ export default function Index() {
新增功能页面时,逐项确认: 新增功能页面时,逐项确认:
1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型 1. ✅ 在 `types/storage.d.ts` 添加 `PageType` 联合类型
2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、label、description、icon、三种渲染模式组件) 2. ✅ 在 `config/features.tsx` 注册 `FEATURES` 配置(key、labelKey、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. ✅ 在页面组件或 `constants.ts` 中编写 UI 文案 7. ✅ 在 `i18n/locales/{zh,en}/` 添加翻译
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` 全部通过
@@ -1107,8 +1103,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、generators | | `src/lib/` | 通用工具函数(cn、utils |
| `public/` | 静态资源(图标等) | | `public/` | 静态资源 |
--- ---
+20 -56
View File
@@ -1,6 +1,23 @@
# AGENTS.md # AGENTS.md
WXT 浏览器扩展项目 (React 19 + TypeScript)。提供时间戳转换、存储清理、JWT 解析、JSON 工具、二维码、Base64、测试数据生成器等测试效率工具。 WXT 浏览器扩展项目 (React 19 + TypeScript)。为开发者和测试人员提供浏览器内效率工具
## 功能列表
功能定义见 `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` | 自定义规则批量生成测试数据 |
## 核心命令 ## 核心命令
@@ -61,7 +78,7 @@ src/ # 源代码根目录
lib/ # 通用工具函数(cn、utils)及数据生成器定义 lib/ # 通用工具函数(cn、utils)及数据生成器定义
workers/ # Web Worker(数据生成等耗时任务) workers/ # Web Worker(数据生成等耗时任务)
spec/ # 功能规格、修复方案与验收标准(见 spec/README.md spec/ # 功能规格、修复方案与验收标准(见 spec/README.md
public/ # 静态资源(图标等) public/ # 静态资源(图标、_locales 等)
.wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑) .wxt/ # wxt prepare 自动生成,含类型声明与扩展 tsconfig(勿手动编辑)
.output/ # 生产构建输出目录 .output/ # 生产构建输出目录
``` ```
@@ -93,38 +110,6 @@ 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`
@@ -132,8 +117,7 @@ src/types/
每种模式有独立的路由和可见页面配置(`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` 快照`snapshot/{key}`消除首屏闪烁。 使用 `src/utils/chromeStorage.ts` 及其 Hook。Router 同时使用 `chrome.storage.local``localStorage` 快照消除首屏闪烁。
异步加载完成前禁止写入 storage(`RouterProvider``canPersistRef``useStorageState``loadSucceededRef`),避免默认值覆盖已有数据。
**通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts` **通信**: 使用 `@webext-core/messaging`,协议定义在 `src/utils/messages.ts`
@@ -152,26 +136,6 @@ src/types/
- 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` (测试文件除外)
+4 -3
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 插件)
- **UI 文案**: 组件内直接使用中文字符串;功能名称与描述定义在 `src/config/features.tsx` - **国际化**: i18next + react-i18next
- **通信**: @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, generators 等) │ └── lib/ # 通用工具函数 (cn, utils 等)
├── public/ # 静态资源 (图标等) ├── public/ # 静态资源 (图标、manifest 资源等)
├── wxt.config.ts # WXT 框架核心配置 ├── wxt.config.ts # WXT 框架核心配置
└── package.json # 项目元数据与依赖管理 └── package.json # 项目元数据与依赖管理
``` ```
@@ -155,6 +155,7 @@
- `sidePanel`: 支持在浏览器侧边栏中运行. - `sidePanel`: 支持在浏览器侧边栏中运行.
- `clipboardWrite`: 提供一键复制功能. - `clipboardWrite`: 提供一键复制功能.
- `contextMenus`: 注册右键菜单,支持快捷操作. - `contextMenus`: 注册右键菜单,支持快捷操作.
- `alarms`: 管理后台定时任务(如清理后自动刷新).
## 浏览器支持 ## 浏览器支持
+1 -1
View File
@@ -5,7 +5,7 @@
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "tailwind.config.js", "config": "tailwind.config.js",
"css": "src/styles/shell.css", "css": "src/index.css",
"baseColor": "slate", "baseColor": "slate",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
+5 -2
View File
@@ -724,14 +724,17 @@ 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 select-none"> <div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none">
{/* 页面内容 */} {/* 页面内容 */}
<div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm"> <div className="p-5 rounded-xl border border-border bg-card text-card-foreground shadow-sm">
<h4 className="font-bold text-sm tracking-tight"></h4> <h4 className="font-bold text-sm tracking-tight">{t('newTool:title')}</h4>
</div> </div>
</div> </div>
); );
+1 -28
View File
@@ -2,7 +2,7 @@
> 版本: v1.0 > 版本: v1.0
> 创建时间: 2024-01-20 > 创建时间: 2024-01-20
> 状态: 已实现(见 `src/pages/TestDataGenerator/`、`src/lib/generators/`、`src/workers/generator.worker.ts` > 状态: 待 Review
## 产品定位 ## 产品定位
@@ -19,30 +19,3 @@
- [规则管理](./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)。
## 开发者注意事项(与源码同步)
以下行为以 `src/` 源码为准;设计文档中的旧版 class API、`metadata`/`options` 嵌套结构已废弃。
### Worker 任务 ID`generationId`
`useGenerator` 每次调用 `generate()` 递增 `generationIdRef`,经 `start` 消息传入 Worker。Worker 所有响应(`progress` / `complete` / `error`)均携带同一 `generationId`
- **取消**`cancel()` 先递增 ID 再发送 `cancel`,使进行中的 Worker 响应被主线程忽略;Worker 每生成 100 行让出事件循环以处理 cancel。
- **快速重试**:新任务 ID 大于旧响应时,旧消息被丢弃,避免 UI 状态错乱。
类型见 `WorkerRequestMessage` / `WorkerResponseMessage``src/types/testDataGenerator.ts`)。
### 规则存储写入失败
`ruleStorage.save()` / `update()``localStorage.setItem` 失败时返回 `null`(不部分提交)。页面层(如 `FieldList.tsx`)仅在返回值非空时 Toast 成功。详见 [rule-management.md § 存储机制](./rule-management.md#存储机制) 与 `src/utils/README.md`
+119 -60
View File
@@ -24,20 +24,25 @@
## 数据结构 ## 数据结构
> **与源码对齐**:类型定义见 `src/types/testDataGenerator.ts`。规则**不**持久化生成数量与导出格式(由页面 `GenerateOptions` 状态管理)。
### 规则模板 ### 规则模板
```typescript ```typescript
interface DataRule { interface DataRule {
id: string; id: string; // 唯一标识
name: string; name: string; // 规则名称
description?: string; description?: string; // 规则描述
fields: FieldConfig[]; fields: FieldConfig[]; // 字段配置
createdAt: number; options: {
updatedAt: number; total: number; // 生成数量
lastUsedAt?: number; format: 'json' | 'csv';
useCount: number; defaultEmptyRate: number; // 默认空值率(0-100
};
metadata: {
createdAt: number; // 创建时间
updatedAt: number; // 更新时间
lastUsedAt?: number; // 最后使用时间
useCount: number; // 使用次数
};
} }
``` ```
@@ -45,14 +50,13 @@ interface DataRule {
```typescript ```typescript
interface FieldConfig { interface FieldConfig {
id: string; id: string; // 字段唯一标识
name: string; name: string; // 字段名
description?: string; generator: string; // 生成器名称
generatorId: string; // 生成器 ID,对应 lib/generators 中的 id params: Record<string, any>; // 生成器参数
params: Record<string, unknown>; unique: boolean; // 唯一性约束
required: boolean; required: boolean; // 是否必填
nullRate: number; // 空值率 0-100,仅 required=false 时生效 emptyRate?: number; // 选填字段的空值概率(0-100)
unique: boolean;
} }
``` ```
@@ -106,9 +110,11 @@ interface FieldConfig {
1. 验证规则名称不为空 1. 验证规则名称不为空
2. 检查规则数量是否达到上限(20 条) 2. 检查规则数量是否达到上限(20 条)
3. 若达上限,`save()` 返回 `null`UI 应阻止或提示 3. 如果达到上限,显示提示"已达到最大规则数量"
4. 生成唯一 ID,写入 `createdAt`/`updatedAt``useCount` 初始为 0 4. 生成唯一 ID
5. 调用 `ruleStorage.save()`;仅当返回值非 `null` 时视为成功(`localStorage` 写入失败同样返回 `null` 5. 设置创建时间和更新时间
6. 初始化使用次数为 0
7. 保存到 localStorage
--- ---
@@ -161,13 +167,17 @@ interface FieldConfig {
- 支持按规则描述搜索 - 支持按规则描述搜索
- 搜索为模糊匹配,不区分大小写 - 搜索为模糊匹配,不区分大小写
**实现方式**`src/utils/ruleStorage.ts`: **实现方式**:
```typescript ```typescript
import * as ruleStorage from '@/utils/ruleStorage'; search(keyword: string): DataRule[] {
const rules = this.getAll();
const lowerKeyword = keyword.toLowerCase();
function searchRules(query: string): DataRule[] { return rules.filter(r =>
return ruleStorage.search(query); r.name.toLowerCase().includes(lowerKeyword) ||
r.description?.toLowerCase().includes(lowerKeyword)
);
} }
``` ```
@@ -214,7 +224,9 @@ function searchRules(query: string): DataRule[] {
1. 加载原规则配置到编辑器 1. 加载原规则配置到编辑器
2. 用户修改配置 2. 用户修改配置
3. 点击保存时调用 `ruleStorage.update()`;返回非 `null` 才更新 `updatedAt` 并提示成功 3. 点击保存时更新规则
4. 更新 metadata.updatedAt
5. 保存到 localStorage
--- ---
@@ -257,12 +269,18 @@ function searchRules(query: string): DataRule[] {
**实现方式**: **实现方式**:
```typescript ```typescript
function loadRule(ruleId: string): void { loadRule(ruleId: string): void {
const rule = ruleStorage.getById(ruleId); const rule = this.storage.getById(ruleId);
if (!rule) return;
setFields(rule.fields); // 应用规则配置
ruleStorage.recordUse(ruleId); this.setFields(rule.fields);
this.setOptions(rule.options);
// 记录使用
this.storage.recordUse(ruleId);
// 更新预览
this.updatePreview();
} }
``` ```
@@ -274,7 +292,7 @@ function loadRule(ruleId: string): void {
1. 点击"复制"按钮 1. 点击"复制"按钮
2. 创建规则的副本 2. 创建规则的副本
3. 名称添加「(副本)」后缀(默认 `duplicate(id, '(副本)')` 3. 名称添加"(副本)"后缀
4. 生成新的 ID 4. 生成新的 ID
5. 保存为新规则 5. 保存为新规则
@@ -309,20 +327,23 @@ function loadRule(ruleId: string): void {
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
**导出格式**`exportRules()` 返回 `DataRule[]` 的 JSON 字符串,无外层包装): **导出格式**:
```json ```json
[ {
{ "version": "1.0",
"id": "rule_123456", "exportedAt": "2024-01-20T10:15:45.000Z",
"name": "电商用户数据 - 测试用", "rules": [
"description": "用于测试用户注册功能", {
"fields": [], "id": "rule_123456",
"createdAt": 1704067200000, "name": "电商用户数据 - 测试用",
"updatedAt": 1704067200000, "description": "用于测试用户注册功能",
"useCount": 0 "fields": [...],
} "options": {...},
] "metadata": {...}
}
]
}
``` ```
--- ---
@@ -380,25 +401,63 @@ function loadRule(ruleId: string): void {
### 本地存储 ### 本地存储
使用 `localStorage`,键名 `testDataGenerator_rules`。API 为**命名导出函数**(见 `src/utils/ruleStorage.ts`): 使用 localStorage 存储规则数据:
| 函数 | 说明 |
| ---- | ---- |
| `getAll()` / `getById()` / `getByName()` | 读取 |
| `save()` / `update()` / `deleteRule()` / `duplicate()` | 写入;失败时返回 `null``false` |
| `recordUse()` | 递增 `useCount`、更新 `lastUsedAt` |
| `search()` / `getRecent()` | 搜索与最近使用 |
| `exportRules()` / `importRules()` | 导入导出 JSON 数组 |
| `clear()` | 清空全部规则 |
写入失败(如 `QuotaExceededError`)时,内部 `setAll()` 返回 `false``save`/`update` 返回 `null``deleteRule` 返回 `false`,并在控制台输出 `[ruleStorage] 保存规则失败`。调用方须检查返回值,避免误报成功。
```typescript ```typescript
import * as ruleStorage from '@/utils/ruleStorage'; class RuleStorage {
private readonly STORAGE_KEY = 'testDataGenerator_rules';
const saved = ruleStorage.save({ name: '示例', fields }); // 获取所有规则
if (!saved) { getAll(): DataRule[] {
// 达上限或 localStorage 不可用 const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : [];
}
// 保存规则
save(rule: DataRule): { success: boolean; message?: string } {
const rules = this.getAll();
// 规则数量限制:最多 20 条
const MAX_RULES = 20;
if (rules.length >= MAX_RULES) {
return {
success: false,
message: `已达到最大规则数量(${MAX_RULES}条),请删除一些规则后再保存`,
};
}
rules.push(rule);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules));
return { success: true };
}
// 更新规则
update(id: string, updates: Partial<DataRule>): void {
const rules = this.getAll();
const index = rules.findIndex((r) => r.id === id);
if (index !== -1) {
rules[index] = { ...rules[index], ...updates };
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules));
}
}
// 删除规则
delete(id: string): void {
const rules = this.getAll();
const filtered = rules.filter((r) => r.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
// 记录使用
recordUse(id: string): void {
const rules = this.getAll();
const rule = rules.find((r) => r.id === id);
if (rule) {
rule.metadata.lastUsedAt = Date.now();
rule.metadata.useCount++;
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(rules));
}
}
} }
``` ```
@@ -1,7 +1,5 @@
# 技术实现 # 技术实现
> **文档同步说明**:本文档含早期设计稿代码示例,部分类型/API 已与实现偏离。开发时请以 `src/types/testDataGenerator.ts`、`src/workers/generator.worker.ts`、`src/pages/TestDataGenerator/hooks/useGenerator.ts`、`src/utils/ruleStorage.ts` 为准。近期变更摘要见 [README § 开发者注意事项](./README.md#开发者注意事项与源码同步)。
## 技术栈 ## 技术栈
| 技术 | 用途 | 版本 | | 技术 | 用途 | 版本 |
@@ -1500,69 +1498,6 @@ export class DataExporter {
## Web Worker 使用 ## Web Worker 使用
### 消息协议
```typescript
// src/types/testDataGenerator.ts
type WorkerRequestMessage =
| { type: 'start'; payload: WorkerStartPayload }
| { type: 'cancel' };
type WorkerResponseMessage =
| { type: 'progress'; generationId: number; payload: GenerateProgress }
| { type: 'complete'; generationId: number; payload: GenerateResult }
| { type: 'error'; generationId: number; payload: { error: string } };
interface WorkerStartPayload {
generationId: number; // 任务 ID,用于忽略过期响应
fields: FieldConfig[];
count: number;
csvMode: boolean;
}
```
### useGenerator Hook
`src/pages/TestDataGenerator/hooks/useGenerator.ts` 负责 Worker 生命周期与 `generationId` 管理:
- Worker **复用**:同一 Hook 实例内只创建一次,出错后 `terminate` 并在下次重建
- **开始生成**`generate(fields, count, csvMode?)` 递增 `generationId` 并 post `start`
- **取消**`cancel()` 递增 ID(使旧响应失效)并 post `cancel`;取消完成的 `complete` 不写入 `error` 状态
- **响应过滤**`onmessage` 中若 `data.generationId !== generationIdRef.current` 则忽略
```typescript
const generate = (fields: FieldConfig[], count: number, csvMode = false) => {
if (isGenerating) return;
const generationId = ++generationIdRef.current;
worker.postMessage({ type: 'start', payload: { generationId, fields, count, csvMode } });
};
const cancel = () => {
if (workerRef.current && isGenerating) {
++generationIdRef.current;
worker.postMessage({ type: 'cancel' });
setIsGenerating(false);
}
};
```
### Worker 实现要点
`src/workers/generator.worker.ts`
-`field.generatorId` 查找生成器;选填字段按 `nullRate` 随机置 `null`
- 唯一性:≤1000 条随机+重试;>1000 条优先 `generateAtIndex`
-`YIELD_EVERY`100)行 `await setTimeout(0)`,以便处理 `cancel`
- 进度:每 1000 条或最后一行 post `progress`
---
## Web Worker 使用(历史设计稿,仅供参考)
<details>
<summary>展开查看旧版设计示例(与当前实现不一致)</summary>
### 创建 Worker ### 创建 Worker
```typescript ```typescript
@@ -1658,7 +1593,7 @@ export function useGenerator() {
} }
``` ```
### 错误处理(设计稿,`useErrorHandler.ts` 未实现) ### 错误处理
```typescript ```typescript
// src/pages/TestDataGenerator/hooks/useErrorHandler.ts // src/pages/TestDataGenerator/hooks/useErrorHandler.ts
@@ -1701,12 +1636,8 @@ export function useErrorHandler(options: ErrorHandlerOptions = {}) {
--- ---
</details>
## 错误提示机制 ## 错误提示机制
> 当前实现:`TestDataGenerator/index.tsx` 直接使用 `useGenerator` 的 `error`/`result` 与 `ResultPanel` 展示警告;下方示例引用未实现的 `useErrorHandler`,仅供对照。
### 错误类型分类 ### 错误类型分类
| 错误类型 | 严重程度 | 触发场景 | 提示方式 | | 错误类型 | 严重程度 | 触发场景 | 提示方式 |
-1
View File
@@ -17,4 +17,3 @@
- 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置 - 修改图标后需同步更新 `wxt.config.ts` 中的 manifest 配置
- 图标格式推荐使用 PNG,确保透明背景 - 图标格式推荐使用 PNG,确保透明背景
- UI 文案不在此目录维护,见 `src/config/features.tsx` 与各页面组件
+6 -3
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { Check, Copy } from 'lucide-react'; import { Check, Copy } from 'lucide-react';
import { copyTextToClipboard } from '@/utils/clipboard'; import { copyTextToClipboard } from '@/utils/clipboard';
import { Button, type ButtonProps } from '@/components/ui/button'; import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> { interface CopyButtonProps extends Omit<ButtonProps, 'children' | 'onClick'> {
@@ -54,10 +53,14 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
aria-label={tooltip ?? '复制'} aria-label={tooltip ?? '复制'}
variant={variant} variant={variant}
size={size} size={size}
className={cn(className, copied && 'text-emerald-500')} className={className}
{...props} {...props}
> >
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} {copied ? (
<Check className="h-[1.2em] w-[1.2em]" />
) : (
<Copy className="h-[1.2em] w-[1.2em]" />
)}
</Button> </Button>
); );
}; };
@@ -1,194 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// unmock the globally-mocked component so we test the real implementation
vi.unmock('@/components/CopyButton');
vi.mock('@/utils/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { CopyButton } from '@/components/CopyButton';
import { copyTextToClipboard } from '@/utils/clipboard';
import { toast } from 'sonner';
const mockedCopy = vi.mocked(copyTextToClipboard);
const mockedToast = vi.mocked(toast);
describe('CopyButton', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('复制成功时调用 copyTextToClipboard 并传入正确 text', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="hello world" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalledWith('hello world');
expect(mockedToast.success).toHaveBeenCalled();
});
it('复制成功后图标切换为 Check1.5 秒后恢复', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="test" />);
// 点击后复制成功,按钮获得 emerald 样式(说明切到了 Check 状态)
await user.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('button').className).toContain('text-emerald');
});
// 1.5 秒后样式恢复
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(screen.getByRole('button').className).not.toContain('text-emerald');
});
});
it('复制空文本时弹出 error toast', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).not.toHaveBeenCalled();
expect(mockedToast.error).toHaveBeenCalled();
});
it('复制失败时弹出 error toast', async () => {
mockedCopy.mockResolvedValue(false);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="something" />);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalledWith('something');
expect(mockedToast.error).toHaveBeenCalled();
});
// ==================== 新增测试 ====================
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
render(<CopyButton text="initial" />);
const button = screen.getByRole('button');
expect(button.className).not.toContain('text-emerald');
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
expect(button).toHaveAttribute('aria-label');
});
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
render(<CopyButton text="tooltip-test" tooltip="自定义提示" />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('title', '自定义提示');
expect(button).toHaveAttribute('aria-label', '自定义提示');
});
it('className 被正确透传到按钮', () => {
render(<CopyButton text="class-test" className="my-custom-class" />);
const button = screen.getByRole('button');
expect(button.className).toContain('my-custom-class');
});
it('点击事件阻止冒泡', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<CopyButton text="stop-propagation" />
</div>,
);
await user.click(screen.getByRole('button'));
expect(mockedCopy).toHaveBeenCalled();
expect(parentClick).not.toHaveBeenCalled();
});
it('组件卸载时清除定时器,不触发状态更新警告', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const { unmount } = render(<CopyButton text="unmount-test" />);
await user.click(screen.getByRole('button'));
// 在 1.5 秒超时到期前卸载组件
act(() => {
vi.advanceTimersByTime(500);
});
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
expect(() => unmount()).not.toThrow();
// 前进剩余时间,确认没有异常
act(() => {
vi.advanceTimersByTime(2000);
});
});
it('快速连续点击不会创建多个重叠定时器', async () => {
mockedCopy.mockResolvedValue(true);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<CopyButton text="rapid-click" />);
const button = screen.getByRole('button');
// 快速点击 3 次
await user.click(button);
await user.click(button);
await user.click(button);
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
expect(mockedCopy).toHaveBeenCalledTimes(3);
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
// advance 1.5 秒后,copied 状态应恢复为 false
act(() => {
vi.advanceTimersByTime(1500);
});
await waitFor(() => {
expect(button.className).not.toContain('text-emerald');
});
});
it('其他 button props 通过 ...props 透传', () => {
render(<CopyButton text="props-test" data-testid="copy-btn" disabled id="copy-button-id" />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('data-testid', 'copy-btn');
expect(button).toBeDisabled();
expect(button).toHaveAttribute('id', 'copy-button-id');
});
});
+5 -7
View File
@@ -30,7 +30,9 @@ export function ErrorFallback({
<div <div
className={cn( className={cn(
'flex flex-col items-center justify-center', 'flex flex-col items-center justify-center',
isApp ? 'mt-16 mx-auto max-w-md' : 'flex-1 p-6 min-h-[300px] fade-in-zoom-95', isApp
? 'mt-16 mx-auto max-w-md'
: 'flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200',
className, className,
)} )}
> >
@@ -55,11 +57,7 @@ export function ErrorFallback({
<h3 className="text-base font-semibold text-foreground mb-1.5">{title}</h3> <h3 className="text-base font-semibold text-foreground mb-1.5">{title}</h3>
)} )}
<p <p className={cn('text-muted-foreground', isApp ? 'text-sm mb-6' : 'text-xs mb-5')}>
className={
isApp ? 'text-sm text-muted-foreground mb-6' : 'text-xs text-muted-foreground mb-5'
}
>
{description} {description}
</p> </p>
@@ -85,7 +83,7 @@ export function ErrorFallback({
variant="destructive" variant="destructive"
size={isApp ? 'default' : 'sm'} size={isApp ? 'default' : 'sm'}
onClick={onAction} onClick={onAction}
className={isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm'} className={cn(isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm')}
> >
<RefreshCw className={isApp ? 'mr-2 h-4 w-4' : 'mr-1.5 h-3.5 w-3.5'} /> <RefreshCw className={isApp ? 'mr-2 h-4 w-4' : 'mr-1.5 h-3.5 w-3.5'} />
{actionLabel} {actionLabel}
+1 -1
View File
@@ -46,7 +46,7 @@ function ToolPageSkeleton() {
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" /> <div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" /> <div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
<div className="flex-1" /> <div className="flex-1" />
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" /> <div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
</div> </div>
{/* 结果区域 */} {/* 结果区域 */}
+1 -1
View File
@@ -10,7 +10,7 @@
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | | `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 | | `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 |
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | | `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
| `CopyButton.tsx` | 一键复制按钮复制成功后 1.5s 内切换为 Check 图标并应用 `text-emerald-500`;空内容/失败时 `toast` 提示 | | `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` `toast` 反馈 |
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 | | `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 | | `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 | | `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
+29 -48
View File
@@ -1,73 +1,54 @@
import { getFeatureByKey } from '@/config/features'; import { FEATURES, getEntryPointType } from '@/config/features';
import { loadPage } from '@/config/pageLoaders/index';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage'; import { Suspense } from 'react';
import { type ComponentType, useEffect, useState } from 'react';
import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageErrorBoundary from '@/components/PageErrorBoundary';
import PageSkeleton from '@/components/PageSkeleton'; import PageSkeleton from '@/components/PageSkeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
function LoadedPage({ pageKey }: { pageKey: PageType }) { const entryPointType = getEntryPointType();
const [Page, setPage] = useState<ComponentType | null>(null);
useEffect(() => {
let cancelled = false;
loadPage(pageKey)
.then((mod) => {
if (!cancelled) {
setPage(() => mod.default);
}
})
.catch((err) => {
console.error('[Router Page Load Error]', err);
});
return () => {
cancelled = true;
};
}, [pageKey]);
if (!Page) {
return <PageSkeleton variant={pageKey === 'dashboard' ? 'dashboard' : 'tool'} />;
}
return <Page />;
}
export default function RouterContainer() { export default function RouterContainer() {
const { currentPage } = useRouter(); const { currentPage, isLoaded } = useRouter();
const animationClass = const animationClass =
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
const currentFeature = getFeatureByKey(currentPage); if (!isLoaded) {
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
}
const currentFeature = FEATURES.find((f) => f.key === currentPage);
const MatchedComponent = currentFeature?.components?.[entryPointType];
return ( return (
<div <div
key={currentPage} key={currentPage}
className={cn( className={cn(
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto', 'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
'motion-reduce:transition-none', 'scrollbar-gutter-stable motion-reduce:transition-none',
animationClass, animationClass,
)} )}
> >
<PageErrorBoundary resetKey={currentPage}> <Suspense
{currentFeature ? ( fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
<LoadedPage key={currentPage} pageKey={currentPage} /> >
) : ( <PageErrorBoundary resetKey={currentPage}>
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center fade-in-300"> {MatchedComponent ? (
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4"> <MatchedComponent />
<AlertTriangle className="h-6 w-6" /> ) : (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
<AlertTriangle className="h-6 w-6" />
</div>
<h3 className="text-sm font-semibold text-foreground">{'页面未找到'}</h3>
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
{`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
</p>
</div> </div>
<h3 className="text-sm font-semibold text-foreground"></h3> )}
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]"> </PageErrorBoundary>
</Suspense>
</p>
</div>
)}
</PageErrorBoundary>
</div> </div>
); );
} }
+2 -1
View File
@@ -24,7 +24,8 @@ const SIZE_CLASSES = {
large: 'text-base h-11 px-4 py-2 rounded-lg', large: 'text-base h-11 px-4 py-2 rounded-lg',
} as const; } as const;
const SELECTED_CLASSES = 'bg-background text-foreground shadow-sm font-semibold fade-in-zoom-95'; const SELECTED_CLASSES =
'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150';
const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80'; const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80';
export default function SwitchButtonGroup<T extends string | number = string>({ export default function SwitchButtonGroup<T extends string | number = string>({
+11 -12
View File
@@ -1,7 +1,7 @@
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner'; // 推荐使用 shadcn 的默认 Toast
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import { Button } from './ui/button'; import { Button } from './ui/button';
@@ -44,6 +44,7 @@ export interface TextInputAreaProps extends Omit<
onClear?: () => void; onClear?: () => void;
} }
// 提炼基础的 ActionButton,全面向 shadcn 核心 Button 样式对齐
function ActionButton({ function ActionButton({
action, action,
value, value,
@@ -125,6 +126,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
const textArea = internalRef.current; const textArea = internalRef.current;
if (!textArea) return; if (!textArea) return;
// 重置高度计算
textArea.style.height = 'auto'; textArea.style.height = 'auto';
const computedMin = minRows * 24; const computedMin = minRows * 24;
@@ -253,6 +255,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
{hasBottomBar && ( {hasBottomBar && (
<div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50"> <div className="flex h-10 items-center justify-between px-4 bg-muted/30 border-t border-border/50">
{/* 左侧自定义动作 */}
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
{bottomActions.map((action) => ( {bottomActions.map((action) => (
<ActionButton <ActionButton
@@ -265,24 +268,19 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
))} ))}
</div> </div>
{/* 右侧系统按钮组 */}
<div className="flex items-center gap-1.5 ml-auto shrink-0"> <div className="flex items-center gap-1.5 ml-auto shrink-0">
{allowCopy && value && ( {allowCopy && value && (
<CopyButton <CopyButton text={value} tooltip={'复制内容'} size="sm" className="h-7 w-7 p-1" />
text={value}
tooltip="复制内容"
variant="ghost"
size="iconSm"
className="text-muted-foreground hover:text-foreground"
/>
)} )}
{showClear && value && !disabled && !readOnly && ( {showClear && value && !disabled && !readOnly && (
<Button <Button
type="button" type="button"
onClick={handleClear} onClick={handleClear}
aria-label="清空" aria-label={'清空'}
variant="ghost" variant="ghost"
size="iconSm" size="icon"
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10" className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
@@ -292,8 +290,9 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
)} )}
</div> </div>
{/* 错误提示 */}
{displayError && ( {displayError && (
<p className="text-xs font-medium text-destructive px-0.5 fade-in-slide-top-1"> <p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
{displayError} {displayError}
</p> </p>
)} )}
+19 -3
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react'; import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
// unmock the globally-mocked component so we test the real implementation // unmock the globally-mocked component so we test the real implementation
@@ -68,10 +68,15 @@ describe('CopyButton', () => {
expect(mockedToast.error).toHaveBeenCalled(); expect(mockedToast.error).toHaveBeenCalled();
}); });
it('初始渲染时带有默认 aria-label', () => { // ==================== 新增测试 ====================
it('初始渲染时显示 Copy 图标且无 emerald 样式', () => {
render(<CopyButton text="initial" />); render(<CopyButton text="initial" />);
expect(screen.getByRole('button')).toHaveAttribute('aria-label', '复制'); const button = screen.getByRole('button');
expect(button.className).not.toContain('text-emerald');
// 通过 aria-label 确认按钮存在,图标由 lucide 渲染为 svg
expect(button).toHaveAttribute('aria-label');
}); });
it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => { it('自定义 tooltip 会覆盖默认 title 和 aria-label', () => {
@@ -114,12 +119,15 @@ describe('CopyButton', () => {
await user.click(screen.getByRole('button')); await user.click(screen.getByRole('button'));
// 在 1.5 秒超时到期前卸载组件
act(() => { act(() => {
vi.advanceTimersByTime(500); vi.advanceTimersByTime(500);
}); });
// 卸载不应抛出 "Can't perform a React state update on an unmounted component" 警告
expect(() => unmount()).not.toThrow(); expect(() => unmount()).not.toThrow();
// 前进剩余时间,确认没有异常
act(() => { act(() => {
vi.advanceTimersByTime(2000); vi.advanceTimersByTime(2000);
}); });
@@ -133,15 +141,23 @@ describe('CopyButton', () => {
const button = screen.getByRole('button'); const button = screen.getByRole('button');
// 快速点击 3 次
await user.click(button); await user.click(button);
await user.click(button); await user.click(button);
await user.click(button); await user.click(button);
// copyTextToClipboard 应该被调用 3 次(每次点击都执行)
expect(mockedCopy).toHaveBeenCalledTimes(3); expect(mockedCopy).toHaveBeenCalledTimes(3);
// 但 setTimeout 相关的 clearTimeout + setTimeout 组合应正常工作
// advance 1.5 秒后,copied 状态应恢复为 false
act(() => { act(() => {
vi.advanceTimersByTime(1500); vi.advanceTimersByTime(1500);
}); });
await waitFor(() => {
expect(button.className).not.toContain('text-emerald');
});
}); });
it('其他 button props 通过 ...props 透传', () => { it('其他 button props 通过 ...props 透传', () => {
@@ -4,13 +4,13 @@ import EmptyPlaceholder from '@/components/EmptyPlaceholder';
describe('EmptyPlaceholder 组件', () => { describe('EmptyPlaceholder 组件', () => {
it('应渲染字符串提示文本', () => { it('应渲染字符串提示文本', () => {
render(<EmptyPlaceholder></EmptyPlaceholder>); render(<EmptyPlaceholder>{'请输入内容'}</EmptyPlaceholder>);
expect(screen.getByText('请输入内容')).toBeInTheDocument(); expect(screen.getByText('请输入内容')).toBeInTheDocument();
}); });
it('应应用规范空状态容器样式', () => { it('应应用规范空状态容器样式', () => {
const { container } = render(<EmptyPlaceholder></EmptyPlaceholder>); const { container } = render(<EmptyPlaceholder>{'提示'}</EmptyPlaceholder>);
const placeholder = container.firstChild; const placeholder = container.firstChild;
expect(placeholder).toHaveClass( expect(placeholder).toHaveClass(
@@ -24,14 +24,14 @@ describe('EmptyPlaceholder 组件', () => {
it('应支持 className 自定义容器样式', () => { it('应支持 className 自定义容器样式', () => {
const { container } = render( const { container } = render(
<EmptyPlaceholder className="flex-1 min-h-[320px]"></EmptyPlaceholder>, <EmptyPlaceholder className="flex-1 min-h-[320px]">{'提示'}</EmptyPlaceholder>,
); );
expect(container.firstChild).toHaveClass('flex-1', 'min-h-[320px]'); expect(container.firstChild).toHaveClass('flex-1', 'min-h-[320px]');
}); });
it('应支持 messageClassName 自定义文本样式', () => { it('应支持 messageClassName 自定义文本样式', () => {
render(<EmptyPlaceholder messageClassName="text-sm max-w-none"></EmptyPlaceholder>); render(<EmptyPlaceholder messageClassName="text-sm max-w-none">{'提示'}</EmptyPlaceholder>);
expect(screen.getByText('提示')).toHaveClass('text-sm', 'max-w-none'); expect(screen.getByText('提示')).toHaveClass('text-sm', 'max-w-none');
}); });
@@ -9,13 +9,12 @@ const mockRouterValue = {
currentPage: 'dashboard' as PageType, currentPage: 'dashboard' as PageType,
visiblePages: ['dashboard', 'timestamp'] as PageType[], visiblePages: ['dashboard', 'timestamp'] as PageType[],
pageOrder: ['timestamp'] as PageType[], pageOrder: ['timestamp'] as PageType[],
isLoaded: true,
navigateTo: vi.fn(), navigateTo: vi.fn(),
syncNavigation: vi.fn(), syncNavigation: vi.fn(),
goHome: vi.fn(), goHome: vi.fn(),
setVisiblePages: vi.fn(), setVisiblePages: vi.fn(),
setPageOrder: vi.fn(), setPageOrder: vi.fn(),
recentlyUsedTools: [] as PageType[],
isLoaded: true,
}; };
vi.mock('@/providers/RouterProvider', () => ({ vi.mock('@/providers/RouterProvider', () => ({
@@ -33,7 +32,16 @@ describe('RouterContainer 组件', () => {
}; };
describe('渲染测试', () => { describe('渲染测试', () => {
it('mount 后应直接渲染页面结构(不等待 storage 加载)', () => { it('isLoaded 为 false 时应渲染骨架屏', () => {
mockRouterValue.isLoaded = false;
const { container } = renderWithProvider(<RouterContainer />);
// 骨架屏使用 animate-pulse 类
const skeletons = container.querySelectorAll('.animate-pulse');
expect(skeletons.length).toBeGreaterThan(0);
});
it('isLoaded 为 true 时应渲染页面内容', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />); const { container } = renderWithProvider(<RouterContainer />);
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument(); expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
@@ -69,7 +77,8 @@ describe('RouterContainer 组件', () => {
}); });
describe('页面级错误隔离', () => { describe('页面级错误隔离', () => {
it('PageErrorBoundary 应包裹页面内容', () => { it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
mockRouterValue.isLoaded = true;
mockRouterValue.currentPage = 'dashboard'; mockRouterValue.currentPage = 'dashboard';
const { container } = renderWithProvider(<RouterContainer />); const { container } = renderWithProvider(<RouterContainer />);
-1
View File
@@ -21,7 +21,6 @@ const buttonVariants = cva(
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10', icon: 'h-10 w-10',
iconSm: 'h-7 w-7',
}, },
}, },
defaultVariants: { defaultVariants: {
+1 -1
View File
@@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator className="grid place-content-center text-current"> <CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
+2 -2
View File
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 transition-opacity duration-200 data-[state=closed]:opacity-0 data-[state=open]:opacity-100', 'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className, className,
)} )}
{...props} {...props}
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg transition-all duration-200 data-[state=closed]:scale-95 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=open]:opacity-100 sm:rounded-lg', 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:scale-95 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=open]:opacity-100', 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className, className,
+11 -13
View File
@@ -67,21 +67,19 @@ describe('features 懒加载', () => {
expect(pageLoadTracker.loaded).toEqual([]); expect(pageLoadTracker.loaded).toEqual([]);
}); });
it('loadPage 应仅加载对应页面模块', async () => { it('渲染 lazy 组件时才应加载对应页面模块', async () => {
const { loadPage } = await import('@/config/pageLoaders/index'); const React = await import('react');
const { render, waitFor } = await import('@testing-library/react');
const { FEATURES } = await import('@/config/features');
await loadPage('dashboard'); const DashboardPage = FEATURES.find((f) => f.key === 'dashboard')!.components.popup;
expect(pageLoadTracker.loaded).toEqual(['Dashboard']); render(
}); React.createElement(React.Suspense, { fallback: null }, React.createElement(DashboardPage)),
);
it('loadPage 切换页面时不应加载无关页面模块', async () => { await waitFor(() => {
const { loadPage } = await import('@/config/pageLoaders/index'); expect(pageLoadTracker.loaded).toEqual(['Dashboard']);
});
await loadPage('timestamp');
expect(pageLoadTracker.loaded).toEqual(['Timestamp']);
expect(pageLoadTracker.loaded).not.toContain('QrCode');
expect(pageLoadTracker.loaded).not.toContain('TestDataGenerator');
}); });
}); });
+5 -1
View File
@@ -19,11 +19,15 @@ describe('features', () => {
expect(feature).toHaveProperty('label'); expect(feature).toHaveProperty('label');
expect(feature).toHaveProperty('description'); expect(feature).toHaveProperty('description');
expect(feature).toHaveProperty('defaultVisible'); expect(feature).toHaveProperty('defaultVisible');
expect(feature).not.toHaveProperty('component'); expect(feature).toHaveProperty('components');
expect(typeof feature.key).toBe('string'); expect(typeof feature.key).toBe('string');
expect(typeof feature.label).toBe('string'); expect(typeof feature.label).toBe('string');
expect(typeof feature.description).toBe('string'); expect(typeof feature.description).toBe('string');
expect(typeof feature.defaultVisible).toBe('boolean'); expect(typeof feature.defaultVisible).toBe('boolean');
expect(typeof feature.components).toBe('object');
expect(feature.components).toHaveProperty('popup');
expect(feature.components).toHaveProperty('sidepanel');
expect(feature.components).toHaveProperty('tab');
if (feature.key !== 'dashboard') { if (feature.key !== 'dashboard') {
expect(feature).toHaveProperty('icon'); expect(feature).toHaveProperty('icon');
-8
View File
@@ -1,8 +0,0 @@
export type { FeatureConfig, PaletteColorKey } from '@/config/featureMeta';
export {
FEATURES,
getAllFeatureKeys,
getDefaultPageOrder,
getDefaultVisibleFeatureKeys,
getFeatureByKey,
} from '@/config/featureMeta';
@@ -1,4 +1,4 @@
import type { ComponentType } from 'react'; import { type ComponentType, lazy } from 'react';
import type { LucideProps } from 'lucide-react'; import type { LucideProps } from 'lucide-react';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { import {
@@ -15,6 +15,18 @@ import {
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info'; export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
// 懒加载页面组件
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
const TimestampPage = lazy(() => import('@/pages/Timestamp'));
const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
const QrCodePage = lazy(() => import('@/pages/QrCode'));
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
const JwtPage = lazy(() => import('@/pages/Jwt'));
const JsonToolsPage = lazy(() => import('@/pages/JsonTools'));
const Base64ConverterPage = lazy(() => import('@/pages/Base64Converter'));
const RightClickRestorerPage = lazy(() => import('@/pages/RightClickRestorer'));
const TestDataGeneratorPage = lazy(() => import('@/pages/TestDataGenerator'));
export interface FeatureConfig { export interface FeatureConfig {
key: PageType; key: PageType;
label: string; label: string;
@@ -22,6 +34,11 @@ export interface FeatureConfig {
themeColorKey?: PaletteColorKey; themeColorKey?: PaletteColorKey;
icon?: ComponentType<LucideProps>; icon?: ComponentType<LucideProps>;
defaultVisible: boolean; defaultVisible: boolean;
components: {
popup: ComponentType;
sidepanel: ComponentType;
tab: ComponentType;
};
} }
export const FEATURES: FeatureConfig[] = [ export const FEATURES: FeatureConfig[] = [
@@ -30,6 +47,11 @@ export const FEATURES: FeatureConfig[] = [
label: '仪表盘', label: '仪表盘',
description: '', description: '',
defaultVisible: true, defaultVisible: true,
components: {
popup: DashboardPage,
sidepanel: DashboardPage,
tab: DashboardPage,
},
}, },
{ {
key: 'timestamp', key: 'timestamp',
@@ -38,6 +60,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'primary', themeColorKey: 'primary',
icon: Clock, icon: Clock,
defaultVisible: true, defaultVisible: true,
components: {
popup: TimestampPage,
sidepanel: TimestampPage,
tab: TimestampPage,
},
}, },
{ {
key: 'storageCleaner', key: 'storageCleaner',
@@ -46,6 +73,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'warning', themeColorKey: 'warning',
icon: Database, icon: Database,
defaultVisible: true, defaultVisible: true,
components: {
popup: StorageCleanerPage,
sidepanel: StorageCleanerPage,
tab: StorageCleanerPage,
},
}, },
{ {
key: 'qrCode', key: 'qrCode',
@@ -54,6 +86,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'success', themeColorKey: 'success',
icon: QrCode, icon: QrCode,
defaultVisible: true, defaultVisible: true,
components: {
popup: QrCodePage,
sidepanel: QrCodePage,
tab: QrCodePage,
},
}, },
{ {
key: 'textStatistics', key: 'textStatistics',
@@ -62,6 +99,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'secondary', themeColorKey: 'secondary',
icon: FileText, icon: FileText,
defaultVisible: true, defaultVisible: true,
components: {
popup: TextStatisticsPage,
sidepanel: TextStatisticsPage,
tab: TextStatisticsPage,
},
}, },
{ {
key: 'jwt', key: 'jwt',
@@ -70,6 +112,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'info', themeColorKey: 'info',
icon: Key, icon: Key,
defaultVisible: true, defaultVisible: true,
components: {
popup: JwtPage,
sidepanel: JwtPage,
tab: JwtPage,
},
}, },
{ {
key: 'jsonTools', key: 'jsonTools',
@@ -78,6 +125,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'primary', themeColorKey: 'primary',
icon: GitCompareArrows, icon: GitCompareArrows,
defaultVisible: true, defaultVisible: true,
components: {
popup: JsonToolsPage,
sidepanel: JsonToolsPage,
tab: JsonToolsPage,
},
}, },
{ {
key: 'base64Converter', key: 'base64Converter',
@@ -86,6 +138,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'info', themeColorKey: 'info',
icon: ArrowLeftRight, icon: ArrowLeftRight,
defaultVisible: true, defaultVisible: true,
components: {
popup: Base64ConverterPage,
sidepanel: Base64ConverterPage,
tab: Base64ConverterPage,
},
}, },
{ {
key: 'rightClickRestorer', key: 'rightClickRestorer',
@@ -94,6 +151,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'success', themeColorKey: 'success',
icon: MousePointerClick, icon: MousePointerClick,
defaultVisible: true, defaultVisible: true,
components: {
popup: RightClickRestorerPage,
sidepanel: RightClickRestorerPage,
tab: RightClickRestorerPage,
},
}, },
{ {
key: 'testDataGenerator', key: 'testDataGenerator',
@@ -102,6 +164,11 @@ export const FEATURES: FeatureConfig[] = [
themeColorKey: 'warning', themeColorKey: 'warning',
icon: FileSpreadsheet, icon: FileSpreadsheet,
defaultVisible: true, defaultVisible: true,
components: {
popup: TestDataGeneratorPage,
sidepanel: TestDataGeneratorPage,
tab: TestDataGeneratorPage,
},
}, },
]; ];
@@ -120,3 +187,14 @@ export function getAllFeatureKeys(): PageType[] {
export function getDefaultPageOrder(): PageType[] { export function getDefaultPageOrder(): PageType[] {
return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key); return FEATURES.filter((f) => f.key !== 'dashboard').map((f) => f.key);
} }
export function getEntryPointType(): 'popup' | 'sidepanel' | 'tab' {
const pathname = window.location.pathname;
if (pathname.includes('sidepanel')) {
return 'sidepanel';
}
if (new URLSearchParams(window.location.search).get('mode') === 'tab') {
return 'tab';
}
return 'popup';
}
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadBase64Converter(): Promise<{ default: ComponentType }> {
return import('@/pages/Base64Converter');
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadDashboard(): Promise<{ default: ComponentType }> {
return import('@/pages/Dashboard');
}
-45
View File
@@ -1,45 +0,0 @@
import type { ComponentType } from 'react';
import type { PageType } from '@/types/storage';
type PageModule = { default: ComponentType };
let pagesStylesLoaded = false;
function ensurePagesStyles(): Promise<unknown> {
if (pagesStylesLoaded) {
return Promise.resolve();
}
pagesStylesLoaded = true;
return import('@/styles/pages.css');
}
export function loadPage(key: PageType): Promise<PageModule> {
return ensurePagesStyles().then(() => {
switch (key) {
case 'dashboard':
return import('@/config/pageLoaders/dashboard').then((m) => m.default());
case 'timestamp':
return import('@/config/pageLoaders/timestamp').then((m) => m.default());
case 'storageCleaner':
return import('@/config/pageLoaders/storageCleaner').then((m) => m.default());
case 'qrCode':
return import('@/config/pageLoaders/qrCode').then((m) => m.default());
case 'textStatistics':
return import('@/config/pageLoaders/textStatistics').then((m) => m.default());
case 'jwt':
return import('@/config/pageLoaders/jwt').then((m) => m.default());
case 'jsonTools':
return import('@/config/pageLoaders/jsonTools').then((m) => m.default());
case 'base64Converter':
return import('@/config/pageLoaders/base64Converter').then((m) => m.default());
case 'rightClickRestorer':
return import('@/config/pageLoaders/rightClickRestorer').then((m) => m.default());
case 'testDataGenerator':
return import('@/config/pageLoaders/testDataGenerator').then((m) => m.default());
default: {
const _exhaustive: never = key;
return Promise.reject(new Error(`Unknown page: ${String(_exhaustive)}`));
}
}
});
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadJsonTools(): Promise<{ default: ComponentType }> {
return import('@/pages/JsonTools');
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadJwt(): Promise<{ default: ComponentType }> {
return import('@/pages/Jwt');
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadQrCode(): Promise<{ default: ComponentType }> {
return import('@/pages/QrCode');
}
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadRightClickRestorer(): Promise<{ default: ComponentType }> {
return import('@/pages/RightClickRestorer');
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadStorageCleaner(): Promise<{ default: ComponentType }> {
return import('@/pages/StorageCleaner');
}
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadTestDataGenerator(): Promise<{ default: ComponentType }> {
return import('@/pages/TestDataGenerator');
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadTextStatistics(): Promise<{ default: ComponentType }> {
return import('@/pages/TextStatistics');
}
-5
View File
@@ -1,5 +0,0 @@
import type { ComponentType } from 'react';
export default function loadTimestamp(): Promise<{ default: ComponentType }> {
return import('@/pages/Timestamp');
}
+28 -2
View File
@@ -1,6 +1,6 @@
import '../../.wxt/types/imports.d.ts'; import '../../.wxt/types/imports.d.ts';
import { browser } from 'wxt/browser'; import { browser } from 'wxt/browser';
import { MessageAction, onMessage } from '@/utils/messages'; import { MessageAction, onMessage, sendMessage } from '@/utils/messages';
import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu'; import { createAllContextMenus, parseContextMenuClick } from '@/utils/contextMenu';
import { saveContextMenuData } from '@/utils/useContextMenuData'; import { saveContextMenuData } from '@/utils/useContextMenuData';
import { mainWorldInjectionScript } from '@/utils/rightClickInjection'; import { mainWorldInjectionScript } from '@/utils/rightClickInjection';
@@ -24,12 +24,38 @@ export default defineBackground(() => {
const { featureKey, payload } = result.data; const { featureKey, payload } = result.data;
try {
const sidePanelState = await browser.storage.local.get('sidePanelOpen');
const isSidePanelOpen = sidePanelState.sidePanelOpen === true;
if (isSidePanelOpen) {
await sendMessage(MessageAction.CONTEXT_MENU_CLICKED, { featureKey, payload });
return;
}
} catch (err) {
console.debug('[Context Menu] Side panel pipeline is not available:', err);
}
await saveContextMenuData({ featureKey, payload }); await saveContextMenuData({ featureKey, payload });
try { try {
await browser.action.openPopup(); await browser.action.openPopup();
} catch (err) { } catch (err) {
console.warn('[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,数据已暂存:', err); console.warn(
'[Context Menu] 自动打开 popup 失败,请手动点击扩展图标,暂存数据已安全保留:',
err,
);
}
});
// 监听扩展图标点击事件,安全激活侧边栏
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
try {
await browser.sidePanel.open({ tabId: tab.id });
} catch (err) {
console.error('Failed to open side panel via extension action clicked:', err);
}
} }
}); });
@@ -4,17 +4,18 @@ import { getTextStats } from '@/utils/textStatistics';
import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover'; import { hidePopover, showTextStatsResult, showTimestampResult } from './uiPopover';
function convertTimestamp(input: string): string { function convertTimestamp(input: string): string {
const invalidText = '无效时间戳';
const num = Number(input.trim()); const num = Number(input.trim());
if (isNaN(num)) { if (isNaN(num)) {
return '无效时间戳'; return invalidText;
} }
// 1e12 区分毫秒/秒级时间戳 // 1e12 判定毫秒/秒级时间戳兼容
const d = num > 1e12 ? new Date(num) : new Date(num * 1000); const d = num > 1e12 ? new Date(num) : new Date(num * 1000);
if (isNaN(d.getTime())) { if (isNaN(d.getTime())) {
return '无效时间戳'; return invalidText;
} }
const year = d.getFullYear(); const year = d.getFullYear();
+20 -5
View File
@@ -2,15 +2,30 @@ import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/layout/TopBar'; import TopBar from '@/layout/TopBar';
import RouterContainer from '@/components/RouterContainer'; import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary'; import ErrorBoundary from '@/components/ErrorBoundary';
import { getEntryPointType } from '@/config/features';
export default function App() { export default function App() {
const entryType = getEntryPointType();
const routerConfig =
entryType === 'tab'
? {
syncKey: 'app/tabRoute' as const,
visiblePagesKey: 'app/tabVisiblePages' as const,
pageOrderKey: 'app/tabPageOrder' as const,
}
: {
syncKey: 'app/popupRoute' as const,
visiblePagesKey: 'app/popupVisiblePages' as const,
pageOrderKey: 'app/popupPageOrder' as const,
};
return ( return (
<RouterProvider <RouterProvider
syncKey="app/popupRoute" syncKey={routerConfig.syncKey}
visiblePagesKey="app/popupVisiblePages" visiblePagesKey={routerConfig.visiblePagesKey}
pageOrderKey="app/popupPageOrder" pageOrderKey={routerConfig.pageOrderKey}
> >
<div className="flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background"> <div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
<TopBar /> <TopBar />
<ErrorBoundary> <ErrorBoundary>
<RouterContainer /> <RouterContainer />
+8 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Tools</title> <title>Testing Tools - 标签页</title>
<meta name="manifest.type" content="browser_action" /> <meta name="manifest.type" content="browser_action" />
<script> <script>
(function () { (function () {
@@ -33,6 +33,13 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
/* If opened in a tab (mode=tab), reset the fixed size */
@media screen and (min-width: 600px) {
html, body {
width: 100%;
height: 100%;
}
}
</style> </style>
</head> </head>
<body> <body>
+4 -1
View File
@@ -1,8 +1,11 @@
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { applyThemeFromSnapshot } from '@/utils/themeSnapshot';
import AppRoot from '@/providers/AppRoot'; import AppRoot from '@/providers/AppRoot';
import '@/styles/shell.css'; import '@/index.css';
import App from './App.tsx'; import App from './App.tsx';
applyThemeFromSnapshot();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot> <AppRoot>
<App /> <App />
+26
View File
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import RouterProvider from '@/providers/RouterProvider';
import TopBar from '@/layout/TopBar';
import RouterContainer from '@/components/RouterContainer';
import ErrorBoundary from '@/components/ErrorBoundary';
import { MessageAction, sendMessage } from '@/utils/messages';
export default function App() {
useEffect(() => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: true });
return () => {
sendMessage(MessageAction.SIDE_PANEL_STATE_CHANGED, { isOpen: false });
};
}, []);
return (
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
<div className="app flex flex-col h-screen w-full overflow-hidden">
<TopBar />
<ErrorBoundary>
<RouterContainer />
</ErrorBoundary>
</div>
</RouterProvider>
);
}
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Testing Tools - Side Panel</title>
<script>
(function () {
try {
var raw = localStorage.getItem('snapshot/app/themeMode');
var mode = raw ? JSON.parse(raw) : 'system';
if (mode !== 'light' && mode !== 'dark' && mode !== 'system') mode = 'system';
var isDark =
mode === 'dark' ||
(mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
} catch (_error) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import ReactDOM from 'react-dom/client';
import { applyThemeFromSnapshot } from '@/utils/themeSnapshot';
import AppRoot from '@/providers/AppRoot';
import '@/index.css';
import App from './App.tsx';
applyThemeFromSnapshot();
ReactDOM.createRoot(document.getElementById('root')!).render(
<AppRoot>
<App />
</AppRoot>,
);
-90
View File
@@ -1,5 +1,3 @@
@config "../../tailwind.shell.config.js";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -92,91 +90,3 @@
::-webkit-scrollbar { ::-webkit-scrollbar {
display: none; display: none;
} }
@layer components {
.page-transition-dashboard {
@apply motion-reduce:transition-none;
}
.page-transition-enter {
@apply motion-reduce:transition-none;
animation: tt-page-enter 200ms ease-out;
}
}
@layer utilities {
.fade-in-150 {
animation: tt-fade-in 150ms ease-out forwards;
}
.fade-in-200 {
animation: tt-fade-in 200ms ease-out forwards;
}
.fade-in-300 {
animation: tt-fade-in 300ms ease-out forwards;
}
.fade-in-zoom-95 {
animation: tt-fade-in-zoom-95 150ms ease-out forwards;
}
.fade-in-slide-top-1 {
animation: tt-fade-in-slide-top-1 150ms ease-out forwards;
}
.fade-in-slide-top-2 {
animation: tt-fade-in-slide-top-2 150ms ease-out forwards;
}
}
@keyframes tt-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes tt-fade-in-zoom-95 {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tt-fade-in-slide-top-1 {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tt-fade-in-slide-top-2 {
from {
opacity: 0;
transform: translateY(-0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes tt-page-enter {
from {
opacity: 0.92;
}
to {
opacity: 1;
}
}
+4 -4
View File
@@ -16,16 +16,16 @@ export default function SearchDropdown({
selectedIndex, selectedIndex,
onSelect, onSelect,
}: SearchDropdownProps) { }: SearchDropdownProps) {
const isSearching = !!searchQuery.trim(); const isSearching = searchQuery.trim().length > 0;
const items = isSearching ? searchResults : recentFeatures; const items = isSearching ? searchResults : recentFeatures;
return ( return (
<div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg fade-in-slide-top-2"> <div className="absolute left-0 right-0 top-full z-50 mt-1.5 max-h-80 overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg animate-in fade-in slide-in-from-top-2 duration-150">
<ul role="listbox" className="p-1.5"> <ul role="listbox" className="p-1.5">
{!isSearching && items.length > 0 && ( {!isSearching && items.length > 0 && (
<li className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60"> <div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
</li> </div>
)} )}
{isSearching && items.length === 0 ? ( {isSearching && items.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground"></li> <li className="px-4 py-6 text-center text-sm text-muted-foreground"></li>
+9 -7
View File
@@ -1,4 +1,4 @@
import { type KeyboardEvent, type RefObject } from 'react'; import { type RefObject } from 'react';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -9,7 +9,7 @@ interface SearchInputProps {
searchQuery: string; searchQuery: string;
onSearchQueryChange: (value: string) => void; onSearchQueryChange: (value: string) => void;
onFocus: () => void; onFocus: () => void;
onKeyDown: (e: KeyboardEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void;
onClear: () => void; onClear: () => void;
} }
@@ -26,6 +26,7 @@ export default function SearchInput({
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60 transition-colors group-focus-within:text-muted-foreground" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60 transition-colors group-focus-within:text-muted-foreground" />
<Input <Input
ref={inputRef} ref={inputRef}
type="text"
placeholder="搜索工具..." placeholder="搜索工具..."
value={searchQuery} value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)} onChange={(e) => onSearchQueryChange(e.target.value)}
@@ -34,7 +35,12 @@ export default function SearchInput({
aria-label="搜索工具..." aria-label="搜索工具..."
className="h-9 rounded-lg border-border/60 bg-muted/40 pl-9 pr-16 shadow-none focus-visible:ring-1 focus-visible:ring-offset-0 placeholder:text-muted-foreground/50" className="h-9 rounded-lg border-border/60 bg-muted/40 pl-9 pr-16 shadow-none focus-visible:ring-1 focus-visible:ring-offset-0 placeholder:text-muted-foreground/50"
/> />
{searchQuery ? ( {!searchQuery && (
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden h-5 -translate-y-1/2 items-center gap-0.5 rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 sm:inline-flex">
{getSearchShortcutLabel()}
</kbd>
)}
{searchQuery && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -45,10 +51,6 @@ export default function SearchInput({
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
) : (
<kbd className="pointer-events-none absolute right-3 top-1/2 hidden h-5 -translate-y-1/2 items-center rounded border border-border/60 bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground/60 sm:inline-flex">
{getSearchShortcutLabel()}
</kbd>
)} )}
</div> </div>
); );
+6 -2
View File
@@ -1,3 +1,4 @@
import React from 'react';
import { type FeatureConfig } from '@/config/features'; import { type FeatureConfig } from '@/config/features';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -7,7 +8,7 @@ interface SearchResultItemProps {
onSelect: (feature: FeatureConfig) => void; onSelect: (feature: FeatureConfig) => void;
} }
export default function SearchResultItem({ feature, selected, onSelect }: SearchResultItemProps) { const SearchResultItem = React.memo(({ feature, selected, onSelect }: SearchResultItemProps) => {
return ( return (
<li <li
role="option" role="option"
@@ -27,4 +28,7 @@ export default function SearchResultItem({ feature, selected, onSelect }: Search
</div> </div>
</li> </li>
); );
} });
SearchResultItem.displayName = 'SearchResultItem';
export default SearchResultItem;
+2
View File
@@ -4,6 +4,8 @@ export const SEARCH_HISTORY_DISPLAY = 5;
export const isSearchHistory = (val: unknown): val is string[] => export const isSearchHistory = (val: unknown): val is string[] =>
Array.isArray(val) && val.every((item) => typeof item === 'string'); Array.isArray(val) && val.every((item) => typeof item === 'string');
/** 根据平台返回搜索框快捷键提示文案 */
export function getSearchShortcutLabel(): string { export function getSearchShortcutLabel(): string {
if (typeof navigator === 'undefined') return 'Ctrl+K';
return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K'; return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
} }
+26 -6
View File
@@ -1,4 +1,4 @@
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import SearchDropdown from './SearchDropdown'; import SearchDropdown from './SearchDropdown';
import SearchInput from './SearchInput'; import SearchInput from './SearchInput';
@@ -8,25 +8,45 @@ import { useTopBar } from './useTopBar';
export default function TopBar() { export default function TopBar() {
const { const {
searchQuery, searchQuery,
showResults,
searchResults, searchResults,
recentFeatures, recentFeatures,
selectedIndex, selectedIndex,
isDashboard, isDashboard,
ThemeIcon, ThemeIcon,
themeTitle, themeTitle,
showDropdown,
containerRef, containerRef,
inputRef, inputRef,
handleSearchQueryChange, setSearchQuery,
handleSearchFocus, setShowResults,
setSelectedIndex,
handleSelectFeature, handleSelectFeature,
handleKeyDown, handleKeyDown,
cycleThemeMode, cycleThemeMode,
handleOpenInTab,
goHome, goHome,
clearSearch, clearSearch,
} = useTopBar(); } = useTopBar();
const actions = [{ id: 'theme', icon: ThemeIcon, title: themeTitle, onClick: cycleThemeMode }]; const actions = [
{ id: 'theme', icon: ThemeIcon, title: themeTitle, onClick: cycleThemeMode },
{
id: 'open-in-tab',
icon: ExternalLink,
title: '在标签页打开',
onClick: () => {
void handleOpenInTab();
},
},
];
const handleSearchQueryChange = (value: string) => {
setSearchQuery(value);
setShowResults(true);
setSelectedIndex(-1);
};
const showDropdown = showResults && (searchQuery.trim() || recentFeatures.length > 0);
return ( return (
<header className="relative z-50 flex h-14 items-center justify-between border-b border-border bg-background px-4"> <header className="relative z-50 flex h-14 items-center justify-between border-b border-border bg-background px-4">
@@ -50,7 +70,7 @@ export default function TopBar() {
inputRef={inputRef} inputRef={inputRef}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={handleSearchQueryChange} onSearchQueryChange={handleSearchQueryChange}
onFocus={handleSearchFocus} onFocus={() => setShowResults(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onClear={clearSearch} onClear={clearSearch}
/> />
+33 -36
View File
@@ -1,34 +1,30 @@
import { import { useEffect, useMemo, useRef, useState, type RefObject } from 'react';
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type RefObject,
} from 'react';
import { Monitor, Moon, Sun } from 'lucide-react'; import { Monitor, Moon, Sun } from 'lucide-react';
import { useRouter } from '@/providers/RouterProvider'; import { useRouter } from '@/providers/RouterProvider';
import { useThemeMode } from '@/providers/ThemeModeProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider';
import { type FeatureConfig, FEATURES } from '@/config/features'; import { type FeatureConfig, FEATURES } from '@/config/features';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import { openExtensionPage } from '@/utils/chromeTabs';
import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants'; import { isSearchHistory, SEARCH_HISTORY_DISPLAY, SEARCH_HISTORY_LIMIT } from './constants';
export interface UseTopBarReturn { export interface UseTopBarReturn {
searchQuery: string; searchQuery: string;
showResults: boolean;
searchResults: FeatureConfig[]; searchResults: FeatureConfig[];
recentFeatures: FeatureConfig[]; recentFeatures: FeatureConfig[];
selectedIndex: number; selectedIndex: number;
isDashboard: boolean; isDashboard: boolean;
ThemeIcon: typeof Sun; ThemeIcon: typeof Sun;
themeTitle: string; themeTitle: string;
showDropdown: boolean;
containerRef: RefObject<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>;
inputRef: RefObject<HTMLInputElement | null>; inputRef: RefObject<HTMLInputElement | null>;
handleSearchQueryChange: (value: string) => void; setSearchQuery: (value: string) => void;
handleSearchFocus: () => void; setShowResults: (value: boolean) => void;
setSelectedIndex: (value: number | ((prev: number) => number)) => void;
handleSelectFeature: (feature: FeatureConfig) => void; handleSelectFeature: (feature: FeatureConfig) => void;
handleKeyDown: (e: ReactKeyboardEvent) => void; handleKeyDown: (e: React.KeyboardEvent) => void;
cycleThemeMode: () => void; cycleThemeMode: () => void;
handleOpenInTab: () => Promise<void>;
goHome: () => void; goHome: () => void;
clearSearch: () => void; clearSearch: () => void;
} }
@@ -49,6 +45,11 @@ export function useTopBar(): UseTopBarReturn {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const handleOpenInTab = async () => {
await openExtensionPage('popup.html', { mode: 'tab' });
window.close();
};
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -80,7 +81,7 @@ export function useTopBar(): UseTopBarReturn {
}); });
}, [searchQuery]); }, [searchQuery]);
const recentFeatures = useMemo(() => { const displayedHistory = useMemo(() => {
if (searchQuery.trim()) return []; if (searchQuery.trim()) return [];
return searchHistory return searchHistory
.slice(0, SEARCH_HISTORY_DISPLAY) .slice(0, SEARCH_HISTORY_DISPLAY)
@@ -89,6 +90,7 @@ export function useTopBar(): UseTopBarReturn {
}, [searchHistory, searchQuery]); }, [searchHistory, searchQuery]);
const saveToHistory = (featureKey: string) => { const saveToHistory = (featureKey: string) => {
if (!featureKey.trim()) return;
setSearchHistory((prev) => setSearchHistory((prev) =>
[featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT), [featureKey, ...prev.filter((h) => h !== featureKey)].slice(0, SEARCH_HISTORY_LIMIT),
); );
@@ -111,10 +113,8 @@ export function useTopBar(): UseTopBarReturn {
const themeTitle = const themeTitle =
mode === 'light' ? '切换到深色模式' : mode === 'dark' ? '切换到系统模式' : '切换到浅色模式'; mode === 'light' ? '切换到深色模式' : mode === 'dark' ? '切换到系统模式' : '切换到浅色模式';
const handleKeyDown = (e: ReactKeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
const isSearching = !!searchQuery.trim(); const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
const items = isSearching ? searchResults : recentFeatures;
const totalItems = items.length;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
@@ -124,13 +124,18 @@ export function useTopBar(): UseTopBarReturn {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev));
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
let feature: FeatureConfig | undefined;
if (selectedIndex >= 0 && selectedIndex < totalItems) { if (selectedIndex >= 0 && selectedIndex < totalItems) {
feature = items[selectedIndex]; if (searchQuery.trim()) {
} else if (isSearching && searchResults.length > 0) { handleSelectFeature(searchResults[selectedIndex]);
feature = searchResults[0]; } else {
const selected = displayedHistory[selectedIndex];
if (selected) {
handleSelectFeature(selected);
}
}
} else if (searchQuery.trim() && searchResults.length > 0) {
handleSelectFeature(searchResults[0]);
} }
if (feature) handleSelectFeature(feature);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setShowResults(false); setShowResults(false);
inputRef.current?.blur(); inputRef.current?.blur();
@@ -142,32 +147,24 @@ export function useTopBar(): UseTopBarReturn {
setSelectedIndex(-1); setSelectedIndex(-1);
}; };
const handleSearchQueryChange = (value: string) => {
setSearchQuery(value);
setShowResults(true);
setSelectedIndex(-1);
};
const handleSearchFocus = () => setShowResults(true);
const showDropdown = showResults && (!!searchQuery.trim() || recentFeatures.length > 0);
return { return {
searchQuery, searchQuery,
showResults,
searchResults, searchResults,
recentFeatures, recentFeatures: displayedHistory,
selectedIndex, selectedIndex,
showDropdown,
isDashboard: currentPage === 'dashboard', isDashboard: currentPage === 'dashboard',
ThemeIcon, ThemeIcon,
themeTitle, themeTitle,
containerRef, containerRef,
inputRef, inputRef,
handleSearchQueryChange, setSearchQuery,
handleSearchFocus, setShowResults,
setSelectedIndex,
handleSelectFeature, handleSelectFeature,
handleKeyDown, handleKeyDown,
cycleThemeMode, cycleThemeMode,
handleOpenInTab,
goHome, goHome,
clearSearch, clearSearch,
}; };
+3 -32
View File
@@ -4,10 +4,9 @@
## 文件说明 ## 文件说明
| 文件/目录 | 用途 | | 文件 | 用途 |
| ------------- | -------------------------------------- | | ---------- | ------------------------------------ |
| `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 | | `utils.ts` | `cn()` 函数 — shadcn/ui 标准工具函数 |
| `generators/` | 测试数据生成器内置生成器库和分类注册表 |
## cn() ## cn()
@@ -25,31 +24,3 @@ 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` | 基础类型生成器(整数、浮点数、布尔值、字符串) |
使用示例:
```typescript
import { getGeneratorById, searchGenerators } from '@/lib/generators';
const generator = getGeneratorById('chineseName');
const value = generator?.generate({});
const matched = searchGenerators('uuid');
```
约束:
- 新增生成器必须实现 `GeneratorDefinition`,并加入所属分类文件的导出列表。
- 需要唯一值或大批量稳定输出时,优先实现 `generateAtIndex(params, index)`
- 新增分类时同步更新 `generatorCategories`,并确保 `GeneratorSelector` 能展示该分类。
+17
View File
@@ -1,6 +1,14 @@
/**
* 基础类型生成器
* 包含:随机整数、随机浮点数、随机字符串、从列表选择
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator'; import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random'; import { randomInt, randomPick } from './random';
/**
* 随机整数生成器
*/
export const randomIntGenerator: GeneratorDefinition = { export const randomIntGenerator: GeneratorDefinition = {
id: 'randomInt', id: 'randomInt',
name: '随机整数', name: '随机整数',
@@ -35,6 +43,9 @@ export const randomIntGenerator: GeneratorDefinition = {
}, },
}; };
/**
* 随机浮点数生成器
*/
export const randomFloat: GeneratorDefinition = { export const randomFloat: GeneratorDefinition = {
id: 'randomFloat', id: 'randomFloat',
name: '随机浮点数', name: '随机浮点数',
@@ -74,6 +85,9 @@ export const randomFloat: GeneratorDefinition = {
}, },
}; };
/**
* 随机字符串生成器
*/
export const randomString: GeneratorDefinition = { export const randomString: GeneratorDefinition = {
id: 'randomString', id: 'randomString',
name: '随机字符串', name: '随机字符串',
@@ -152,6 +166,9 @@ export const randomString: GeneratorDefinition = {
}, },
}; };
/**
* 从列表选择生成器
*/
export const fromList: GeneratorDefinition = { export const fromList: GeneratorDefinition = {
id: 'fromList', id: 'fromList',
name: '从列表选择', name: '从列表选择',
+47
View File
@@ -1,6 +1,14 @@
/**
* 业务数据生成器
* 包含:订单号、价格、日期、状态、数量、评分、折扣、库存
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator'; import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random'; import { randomInt, randomPick } from './random';
/**
* 格式化日期
*/
function formatDate(date: Date, format: string): string { function formatDate(date: Date, format: string): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -18,6 +26,9 @@ function formatDate(date: Date, format: string): string {
.replace('ss', seconds); .replace('ss', seconds);
} }
/**
* 订单号生成器
*/
export const orderId: GeneratorDefinition = { export const orderId: GeneratorDefinition = {
id: 'orderId', id: 'orderId',
name: '订单号', name: '订单号',
@@ -57,6 +68,9 @@ export const orderId: GeneratorDefinition = {
}, },
}; };
/**
* 价格生成器
*/
export const price: GeneratorDefinition = { export const price: GeneratorDefinition = {
id: 'price', id: 'price',
name: '价格', name: '价格',
@@ -128,6 +142,9 @@ export const price: GeneratorDefinition = {
}, },
}; };
/**
* 生成心理定价(如 9.99, 19.9, 99
*/
function generatePsychologicalPrice(min: number, max: number): number { function generatePsychologicalPrice(min: number, max: number): number {
const patterns = [ const patterns = [
() => { () => {
@@ -150,6 +167,9 @@ function generatePsychologicalPrice(min: number, max: number): number {
return price; return price;
} }
/**
* 生成真实分布价格(对数正态分布)
*/
function generateRealisticPrice(min: number, max: number): number { function generateRealisticPrice(min: number, max: number): number {
const mean = (Math.log(min) + Math.log(max)) / 2; const mean = (Math.log(min) + Math.log(max)) / 2;
const stdDev = (Math.log(max) - Math.log(min)) / 4; const stdDev = (Math.log(max) - Math.log(min)) / 4;
@@ -162,6 +182,9 @@ function generateRealisticPrice(min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
/**
* 日期生成器
*/
export const date: GeneratorDefinition = { export const date: GeneratorDefinition = {
id: 'date', id: 'date',
name: '日期', name: '日期',
@@ -212,6 +235,9 @@ export const date: GeneratorDefinition = {
}, },
}; };
/**
* 状态生成器
*/
export const status: GeneratorDefinition = { export const status: GeneratorDefinition = {
id: 'status', id: 'status',
name: '状态', name: '状态',
@@ -264,6 +290,9 @@ export const status: GeneratorDefinition = {
}, },
}; };
/**
* 数量生成器
*/
export const quantity: GeneratorDefinition = { export const quantity: GeneratorDefinition = {
id: 'quantity', id: 'quantity',
name: '数量', name: '数量',
@@ -310,6 +339,9 @@ export const quantity: GeneratorDefinition = {
}, },
}; };
/**
* 泊松分布随机数
*/
function poissonRandom(lambda: number): number { function poissonRandom(lambda: number): number {
const L = Math.exp(-lambda); const L = Math.exp(-lambda);
let k = 0; let k = 0;
@@ -321,6 +353,9 @@ function poissonRandom(lambda: number): number {
return k - 1; return k - 1;
} }
/**
* 评分生成器
*/
export const rating: GeneratorDefinition = { export const rating: GeneratorDefinition = {
id: 'rating', id: 'rating',
name: '评分', name: '评分',
@@ -375,6 +410,9 @@ export const rating: GeneratorDefinition = {
}, },
}; };
/**
* 偏向高分的随机数(模拟真实评分分布)
*/
function skewedRandom(min: number, max: number): number { function skewedRandom(min: number, max: number): number {
// 使用 Beta 分布模拟评分偏向 // 使用 Beta 分布模拟评分偏向
const alpha = 3; const alpha = 3;
@@ -387,6 +425,9 @@ function skewedRandom(min: number, max: number): number {
return min + x * (max - min); return min + x * (max - min);
} }
/**
* 折扣生成器
*/
export const discount: GeneratorDefinition = { export const discount: GeneratorDefinition = {
id: 'discount', id: 'discount',
name: '折扣', name: '折扣',
@@ -438,6 +479,9 @@ export const discount: GeneratorDefinition = {
}, },
}; };
/**
* 生成心理折扣(如 5%, 10%, 20%, 30%
*/
function generatePsychologicalDiscount(min: number, max: number): number { function generatePsychologicalDiscount(min: number, max: number): number {
const psychologicalDiscounts = [5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80]; const psychologicalDiscounts = [5, 10, 15, 20, 25, 30, 40, 50, 60, 70, 80];
const validDiscounts = psychologicalDiscounts.filter((d) => d >= min && d <= max); const validDiscounts = psychologicalDiscounts.filter((d) => d >= min && d <= max);
@@ -447,6 +491,9 @@ function generatePsychologicalDiscount(min: number, max: number): number {
return randomInt(min, max); return randomInt(min, max);
} }
/**
* 库存生成器
*/
export const stock: GeneratorDefinition = { export const stock: GeneratorDefinition = {
id: 'stock', id: 'stock',
name: '库存', name: '库存',
+20
View File
@@ -1,9 +1,17 @@
/**
* 生成器库索引
* 导出所有生成器和分类配置
*/
import type { GeneratorDefinition, GeneratorCategory } from '@/types/testDataGenerator'; import type { GeneratorDefinition, GeneratorCategory } from '@/types/testDataGenerator';
import { personalGenerators } from './personal'; import { personalGenerators } from './personal';
import { businessGenerators } from './business'; import { businessGenerators } from './business';
import { technicalGenerators } from './technical'; import { technicalGenerators } from './technical';
import { basicGenerators } from './basic'; import { basicGenerators } from './basic';
/**
* 生成器分类配置
*/
export const generatorCategories: GeneratorCategory[] = [ export const generatorCategories: GeneratorCategory[] = [
{ id: 'personal', name: '个人信息', icon: 'User' }, { id: 'personal', name: '个人信息', icon: 'User' },
{ id: 'business', name: '业务数据', icon: 'Briefcase' }, { id: 'business', name: '业务数据', icon: 'Briefcase' },
@@ -11,6 +19,9 @@ export const generatorCategories: GeneratorCategory[] = [
{ id: 'basic', name: '基础类型', icon: 'Hash' }, { id: 'basic', name: '基础类型', icon: 'Hash' },
]; ];
/**
* 所有生成器列表
*/
export const allGenerators: GeneratorDefinition[] = [ export const allGenerators: GeneratorDefinition[] = [
...personalGenerators, ...personalGenerators,
...businessGenerators, ...businessGenerators,
@@ -18,14 +29,23 @@ export const allGenerators: GeneratorDefinition[] = [
...basicGenerators, ...basicGenerators,
]; ];
/**
* 根据 ID 获取生成器
*/
export function getGeneratorById(id: string): GeneratorDefinition | undefined { export function getGeneratorById(id: string): GeneratorDefinition | undefined {
return allGenerators.find((g) => g.id === id); return allGenerators.find((g) => g.id === id);
} }
/**
* 根据分类 ID 获取生成器列表
*/
export function getGeneratorsByCategory(categoryId: string): GeneratorDefinition[] { export function getGeneratorsByCategory(categoryId: string): GeneratorDefinition[] {
return allGenerators.filter((g) => g.categoryId === categoryId); return allGenerators.filter((g) => g.categoryId === categoryId);
} }
/**
* 搜索生成器
*/
export function searchGenerators(query: string): GeneratorDefinition[] { export function searchGenerators(query: string): GeneratorDefinition[] {
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
return allGenerators.filter( return allGenerators.filter(
+42
View File
@@ -1,6 +1,12 @@
/**
* 个人信息生成器
* 包含:中文姓名、邮箱、手机号、身份证号、中文地址、年龄
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator'; import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random'; import { randomInt, randomPick } from './random';
// 中文姓氏
const SURNAMES = [ const SURNAMES = [
'王', '王',
'李', '李',
@@ -54,6 +60,7 @@ const SURNAMES = [
'阎', '阎',
]; ];
// 中文名字常用字
const NAME_CHARS = [ const NAME_CHARS = [
'伟', '伟',
'芳', '芳',
@@ -107,8 +114,10 @@ const NAME_CHARS = [
'慧', '慧',
]; ];
// 邮箱域名
const EMAIL_DOMAINS = ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com', 'hotmail.com']; const EMAIL_DOMAINS = ['qq.com', '163.com', '126.com', 'gmail.com', 'outlook.com', 'hotmail.com'];
// 省份
const PROVINCES = [ const PROVINCES = [
'北京市', '北京市',
'天津市', '天津市',
@@ -144,6 +153,7 @@ const PROVINCES = [
'新疆维吾尔自治区', '新疆维吾尔自治区',
]; ];
// 城市
const CITIES: Record<string, string[]> = { const CITIES: Record<string, string[]> = {
: ['东城区', '西城区', '朝阳区', '海淀区'], : ['东城区', '西城区', '朝阳区', '海淀区'],
: ['黄浦区', '徐汇区', '长宁区', '静安区'], : ['黄浦区', '徐汇区', '长宁区', '静安区'],
@@ -153,6 +163,9 @@ const CITIES: Record<string, string[]> = {
: ['成都市', '绵阳市', '德阳市', '宜宾市'], : ['成都市', '绵阳市', '德阳市', '宜宾市'],
}; };
/**
* 中文姓名生成器
*/
export const chineseName: GeneratorDefinition = { export const chineseName: GeneratorDefinition = {
id: 'chineseName', id: 'chineseName',
name: '中文姓名', name: '中文姓名',
@@ -166,6 +179,9 @@ export const chineseName: GeneratorDefinition = {
}, },
}; };
/**
* 邮箱生成器
*/
export const email: GeneratorDefinition = { export const email: GeneratorDefinition = {
id: 'email', id: 'email',
name: '邮箱', name: '邮箱',
@@ -192,6 +208,9 @@ export const email: GeneratorDefinition = {
}, },
}; };
/**
* 手机号生成器
*/
export const chinesePhone: GeneratorDefinition = { export const chinesePhone: GeneratorDefinition = {
id: 'chinesePhone', id: 'chinesePhone',
name: '手机号', name: '手机号',
@@ -222,6 +241,9 @@ export const chinesePhone: GeneratorDefinition = {
}, },
}; };
/**
* 身份证号生成器
*/
export const idCard: GeneratorDefinition = { export const idCard: GeneratorDefinition = {
id: 'idCard', id: 'idCard',
name: '身份证号', name: '身份证号',
@@ -246,12 +268,20 @@ export const idCard: GeneratorDefinition = {
generate: (params) => { generate: (params) => {
const regions = ['110101', '310101', '440103', '440305', '510104', '330102']; const regions = ['110101', '310101', '440103', '440305', '510104', '330102'];
const region = params.region === 'random' ? randomPick(regions) : (params.region as string); const region = params.region === 'random' ? randomPick(regions) : (params.region as string);
// 出生日期 1960-2005年
const year = randomInt(1960, 2005); const year = randomInt(1960, 2005);
const month = String(randomInt(1, 12)).padStart(2, '0'); const month = String(randomInt(1, 12)).padStart(2, '0');
const day = String(randomInt(1, 28)).padStart(2, '0'); const day = String(randomInt(1, 28)).padStart(2, '0');
const birthday = `${year}${month}${day}`; const birthday = `${year}${month}${day}`;
// 顺序码
const sequence = String(randomInt(1, 999)).padStart(3, '0'); const sequence = String(randomInt(1, 999)).padStart(3, '0');
// 前17位
const prefix17 = region + birthday + sequence; const prefix17 = region + birthday + sequence;
// 校验码
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']; const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0; let sum = 0;
@@ -287,6 +317,9 @@ export const idCard: GeneratorDefinition = {
}, },
}; };
/**
* 中文地址生成器
*/
export const chineseAddress: GeneratorDefinition = { export const chineseAddress: GeneratorDefinition = {
id: 'chineseAddress', id: 'chineseAddress',
name: '中文地址', name: '中文地址',
@@ -315,6 +348,9 @@ export const chineseAddress: GeneratorDefinition = {
}, },
}; };
/**
* 年龄生成器
*/
export const age: GeneratorDefinition = { export const age: GeneratorDefinition = {
id: 'age', id: 'age',
name: '年龄', name: '年龄',
@@ -366,6 +402,9 @@ export const age: GeneratorDefinition = {
}, },
}; };
/**
* 正态分布随机数
*/
function normalRandom(min: number, max: number): number { function normalRandom(min: number, max: number): number {
const mean = (min + max) / 2; const mean = (min + max) / 2;
const stdDev = (max - min) / 6; const stdDev = (max - min) / 6;
@@ -378,6 +417,9 @@ function normalRandom(min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
/**
* 人口统计分布随机数(模拟真实年龄分布)
*/
function demographicRandom(): number { function demographicRandom(): number {
const ageGroups = [ const ageGroups = [
{ min: 0, max: 14, weight: 0.18 }, { min: 0, max: 14, weight: 0.18 },
+31
View File
@@ -1,6 +1,14 @@
/**
* 技术数据生成器
* 包含:UUID、IPv4、URL
*/
import type { GeneratorDefinition } from '@/types/testDataGenerator'; import type { GeneratorDefinition } from '@/types/testDataGenerator';
import { randomInt, randomPick } from './random'; import { randomInt, randomPick } from './random';
/**
* UUID 生成器
*/
export const uuid: GeneratorDefinition = { export const uuid: GeneratorDefinition = {
id: 'uuid', id: 'uuid',
name: 'UUID', name: 'UUID',
@@ -57,6 +65,9 @@ export const uuid: GeneratorDefinition = {
}, },
}; };
/**
* 生成 UUID v4
*/
function generateUUIDv4(): string { function generateUUIDv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0; const r = (Math.random() * 16) | 0;
@@ -65,6 +76,9 @@ function generateUUIDv4(): string {
}); });
} }
/**
* 生成 UUID v1(简化版本,模拟时间戳)
*/
function generateUUIDv1(): string { function generateUUIDv1(): string {
const now = Date.now(); const now = Date.now();
const timeLow = (now & 0xffffffff).toString(16).padStart(8, '0'); const timeLow = (now & 0xffffffff).toString(16).padStart(8, '0');
@@ -78,6 +92,9 @@ function generateUUIDv1(): string {
return `${timeLow}-${timeMid}-${timeHi.toString(16)}-${clockSeq.toString(16)}-${node}`; return `${timeLow}-${timeMid}-${timeHi.toString(16)}-${clockSeq.toString(16)}-${node}`;
} }
/**
* IPv4 生成器
*/
export const ipv4: GeneratorDefinition = { export const ipv4: GeneratorDefinition = {
id: 'ipv4', id: 'ipv4',
name: 'IPv4', name: 'IPv4',
@@ -112,10 +129,16 @@ export const ipv4: GeneratorDefinition = {
}, },
}; };
/**
* 生成完全随机 IPv4
*/
function generateRandomIPv4(): string { function generateRandomIPv4(): string {
return Array.from({ length: 4 }, () => randomInt(0, 255)).join('.'); return Array.from({ length: 4 }, () => randomInt(0, 255)).join('.');
} }
/**
* 生成内网 IPv4
*/
function generatePrivateIPv4(): string { function generatePrivateIPv4(): string {
const ranges = [ const ranges = [
{ prefix: '10', second: () => randomInt(0, 255) }, { prefix: '10', second: () => randomInt(0, 255) },
@@ -130,6 +153,9 @@ function generatePrivateIPv4(): string {
return `${range.prefix}.${range.second()}.${randomInt(1, 254)}.${randomInt(1, 254)}`; return `${range.prefix}.${range.second()}.${randomInt(1, 254)}.${randomInt(1, 254)}`;
} }
/**
* 生成公网 IPv4
*/
function generatePublicIPv4(): string { function generatePublicIPv4(): string {
let first: number; let first: number;
do { do {
@@ -139,6 +165,9 @@ function generatePublicIPv4(): string {
return `${first}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`; return `${first}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`;
} }
/**
* URL 生成器
*/
export const url: GeneratorDefinition = { export const url: GeneratorDefinition = {
id: 'url', id: 'url',
name: 'URL', name: 'URL',
@@ -208,6 +237,8 @@ export const url: GeneratorDefinition = {
path = `/${randomPick(['about', 'contact', 'products', 'services', 'blog'])}`; path = `/${randomPick(['about', 'contact', 'products', 'services', 'blog'])}`;
} }
} }
// 添加查询参数
let query = ''; let query = '';
if (Math.random() > 0.5) { if (Math.random() > 0.5) {
const params = new URLSearchParams(); const params = new URLSearchParams();
+2 -1
View File
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from 'clsx'; import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return clsx(inputs); return twMerge(clsx(inputs));
} }
@@ -2,6 +2,15 @@ import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import Base64ConverterPage from '../index'; import Base64ConverterPage from '../index';
// Mock getEntryPointType
vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>();
return {
...actual,
getEntryPointType: () => 'sidepanel',
};
});
// Mock 子组件 // Mock 子组件
vi.mock('../components/TextMode', () => ({ vi.mock('../components/TextMode', () => ({
default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => ( default: ({ onSwitchToImageMode }: { onSwitchToImageMode?: () => void }) => (
@@ -13,9 +22,7 @@ vi.mock('../components/TextMode', () => ({
})); }));
vi.mock('../components/Base64ConverterSection', () => ({ vi.mock('../components/Base64ConverterSection', () => ({
default: ({ mode }: { mode: 'file' | 'image' }) => ( default: ({ mode }: { mode: string }) => <div data-testid={`${mode}-mode`}>{mode}</div>,
<div data-testid={`${mode}-mode`}>{mode.toUpperCase()} Mode</div>
),
})); }));
const waitForStorageInit = () => const waitForStorageInit = () =>
@@ -40,16 +40,20 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
decodedFileName, decodedFileName,
setCustomFileName, setCustomFileName,
resetAll, resetAll,
handleFileSelect, safeFileSelect,
maxFileSizeStr, maxFileSizeStr,
} = useBase64Converter({ mode }); } = useBase64Converter({ mode });
const handleDirectionChange = (next: Base64ConvertDirection) => { const handleDirectionChange = (next: Base64ConvertDirection) => {
if (next === direction) return; if (!next || next === direction) return;
resetAll(); resetAll();
setDirection(next); setDirection(next);
}; };
const handleDownload = () => {
if (decoded) downloadBlob(decoded.blob, decodedFileName);
};
return ( return (
<div className="w-full flex flex-col space-y-4 px-2"> <div className="w-full flex flex-col space-y-4 px-2">
<div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit"> <div className="flex h-11 items-center px-1.5 bg-secondary/40 rounded-xl border border-border/60 w-fit">
@@ -76,7 +80,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
e.preventDefault(); e.preventDefault();
setIsDragging(false); setIsDragging(false);
const file = e.dataTransfer.files[0]; const file = e.dataTransfer.files[0];
if (file) void handleFileSelect(file); if (file) safeFileSelect(file);
}} }}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={cn( className={cn(
@@ -95,7 +99,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
hidden hidden
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) void handleFileSelect(file); if (file) safeFileSelect(file);
}} }}
/> />
{isLoading ? ( {isLoading ? (
@@ -111,7 +115,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
/> />
</div> </div>
)} )}
<Upload className="w-8 h-8 text-primary" /> <Upload className={cn('w-8 h-8 text-primary')} />
<span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate"> <span className="text-sm font-bold text-foreground/90 max-w-[280px] truncate">
{info.name} {info.name}
</span> </span>
@@ -119,7 +123,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{formatBytes(info.size)} · {info.type} {formatBytes(info.size)} · {info.type}
</span> </span>
<span className="text-[11px] font-medium text-primary/80 mt-1"> <span className="text-[11px] font-medium text-primary/80 mt-1">
{'点击或拖拽以替换文件'}
</span> </span>
</div> </div>
) : ( ) : (
@@ -133,11 +137,11 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
{mode === 'image' ? '点击或拖拽图像到此处' : '点击或拖拽文件到此处'} {mode === 'image' ? '点击或拖拽图像到此处' : '点击或拖拽文件到此处'}
</span> </span>
<span className="text-[10px] font-medium text-muted-foreground/60"> <span className="text-[10px] font-medium text-muted-foreground/60">
{maxFileSizeStr} {`最大文件大小:${maxFileSizeStr}`}
</span> </span>
{mode === 'image' && ( {mode === 'image' && (
<span className="text-[10px] font-medium text-muted-foreground/50"> <span className="text-[10px] font-medium text-muted-foreground/50">
PNGJPGWEBPGIFBMPSVG {'支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式'}
</span> </span>
)} )}
</div> </div>
@@ -154,17 +158,17 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3"> <div className="p-4 rounded-2xl bg-card border border-border shadow-sm flex flex-col space-y-3">
<div className="flex justify-between items-center select-none"> <div className="flex justify-between items-center select-none">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
Base64 {'Base64 编码结果'}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<CopyButton <CopyButton
text={result.rawBase64} text={result.rawBase64}
tooltip="复制纯 Base64" tooltip={'复制纯 Base64'}
className="h-6 px-2 rounded-md border text-[10px] font-bold" className="h-6 px-2 rounded-md border text-[10px] font-bold"
/> />
<CopyButton <CopyButton
text={result.output} text={result.output}
tooltip="复制 Data URI" tooltip={'复制 Data URI'}
className="h-6 px-2 rounded-md border text-[10px] font-bold" className="h-6 px-2 rounded-md border text-[10px] font-bold"
/> />
</div> </div>
@@ -182,14 +186,14 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
<div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1"> <div className="flex items-center justify-between font-mono text-[10px] text-muted-foreground/70 select-none pt-1">
<div className="flex gap-4 items-center tabular-nums"> <div className="flex gap-4 items-center tabular-nums">
<span> <span>
:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.originalBytes)} {formatBytes(result.originalBytes)}
</span> </span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
<span> <span>
:{' '} {'编码大小'}:{' '}
<span className="font-semibold text-foreground/80"> <span className="font-semibold text-foreground/80">
{formatBytes(result.outputBytes)} {formatBytes(result.outputBytes)}
</span> </span>
@@ -202,7 +206,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2" className="h-7 rounded-md text-muted-foreground hover:text-destructive text-[11px] gap-1 px-2"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
{'清空'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -211,7 +215,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
) : ( ) : (
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<TextInputArea <TextInputArea
placeholder="输入需要解码的 Base64 或 data URI..." placeholder={'输入需要解码的 Base64 或 data URI...'}
value={decodeInput} value={decodeInput}
onChange={setDecodeInput} onChange={setDecodeInput}
externalError={decodeError || undefined} externalError={decodeError || undefined}
@@ -227,7 +231,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP
blobSize={decoded.blob.size} blobSize={decoded.blob.size}
fileName={decodedFileName} fileName={decodedFileName}
onFileNameChange={setCustomFileName} onFileNameChange={setCustomFileName}
onDownload={() => downloadBlob(decoded.blob, decodedFileName)} onDownload={handleDownload}
> >
{mode === 'image' && ( {mode === 'image' && (
<div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm"> <div className="relative p-1.5 border border-border bg-background dark:bg-muted/10 rounded-xl max-w-[220px] mb-3 overflow-hidden shadow-sm">
@@ -1,3 +1,12 @@
/**
* DecodeResultPaper
*
* FileMode 与 ImageMode 通用的 decode 结果展示组件。
* 提取了二者 decode 输出区完全一致的结构:
* 标题 → 可选预览(children)→ 文件信息 → 文件名输入 → 下载按钮
*
* FileMode 直接使用,ImageMode 通过 children 传入图片预览。
*/
import { Download } from 'lucide-react'; import { Download } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -5,12 +14,19 @@ import { Label } from '@/components/ui/label';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
interface DecodeResultPaperProps { interface DecodeResultPaperProps {
/** 标题文案 */
title: string; title: string;
/** 解码后推断的 MIME 类型 */
mimeType: string; mimeType: string;
/** 解码后 Blob 的大小(字节) */
blobSize: number; blobSize: number;
/** 当前文件名 */
fileName: string; fileName: string;
/** 文件名变更回调 */
onFileNameChange: (name: string) => void; onFileNameChange: (name: string) => void;
/** 下载按钮点击回调 */
onDownload: () => void; onDownload: () => void;
/** 可选的预览内容,ImageMode 用于渲染图片预览 */
children?: React.ReactNode; children?: React.ReactNode;
} }
@@ -25,20 +41,31 @@ export default function DecodeResultPaper({
}: DecodeResultPaperProps) { }: DecodeResultPaperProps) {
return ( return (
<div className="p-4 rounded-xl bg-primary/10 border border-primary/30"> <div className="p-4 rounded-xl bg-primary/10 border border-primary/30">
{/* 标题 */}
<span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span> <span className="block mb-2 text-xs font-bold text-muted-foreground">{title}</span>
{/* 可选预览内容(ImageMode 的图片) */}
{children} {children}
{/* 文件信息 */}
<div className="flex gap-4 mb-3"> <div className="flex gap-4 mb-3">
<span className="text-xs text-muted-foreground"> MIME : {mimeType}</span> <span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground">: {formatBytes(blobSize)}</span> {'推断的 MIME 类型'}: {mimeType}
</span>
<span className="text-xs text-muted-foreground">
{'解码大小'}: {formatBytes(blobSize)}
</span>
</div> </div>
{/* 文件名输入 */}
<div className="mb-3"> <div className="mb-3">
<Label className="block text-xs font-medium text-muted-foreground mb-1"></Label> <Label className="block text-xs font-medium text-muted-foreground mb-1">
{'解码后文件名'}
</Label>
<Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} /> <Input value={fileName} onChange={(e) => onFileNameChange(e.target.value)} />
</div> </div>
{/* 下载按钮 */}
<Button <Button
variant="default" variant="default"
onClick={onDownload} onClick={onDownload}
@@ -46,7 +73,7 @@ export default function DecodeResultPaper({
className="w-full rounded-lg font-bold" className="w-full rounded-lg font-bold"
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{'下载'}
</Button> </Button>
</div> </div>
); );
@@ -8,7 +8,7 @@ interface TextModeProps {
onSwitchToImageMode?: () => void; onSwitchToImageMode?: () => void;
} }
export default function TextMode({ onSwitchToImageMode }: TextModeProps) { export default function TextMode({ onSwitchToImageMode }: TextModeProps = {}) {
const { const {
input, input,
setInput, setInput,
@@ -93,7 +93,16 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
[mode], [mode],
); );
const decodeResult = useMemo(() => { const safeFileSelect = useCallback(
(file: File) => {
handleFileSelect(file).catch((err) => {
console.error(`Base64 [${mode}] pipeline crash:`, err);
});
},
[handleFileSelect, mode],
);
const decodePipeline = useMemo(() => {
const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim(); const cleanedInput = debouncedDecodeInput.replace(/^data:image\/[a-z+]+;base64,/i, '').trim();
if (!cleanedInput) return { decoded: null, error: null }; if (!cleanedInput) return { decoded: null, error: null };
@@ -109,8 +118,8 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
} }
}, [debouncedDecodeInput]); }, [debouncedDecodeInput]);
const decoded = decodeResult.decoded; const decoded = decodePipeline.decoded;
const decodeError = decodeResult.error; const decodeError = decodePipeline.error;
const decodedFileName = useMemo(() => { const decodedFileName = useMemo(() => {
if (customFileName) return customFileName; if (customFileName) return customFileName;
@@ -135,7 +144,7 @@ export function useBase64Converter({ mode }: UseBase64ConverterProps) {
decodedFileName, decodedFileName,
setCustomFileName, setCustomFileName,
resetAll, resetAll,
handleFileSelect, safeFileSelect,
maxFileSizeStr, maxFileSizeStr,
}; };
} }
+13 -8
View File
@@ -4,7 +4,7 @@ import { useContextMenuData } from '@/utils/useContextMenuData';
const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i; const IMAGE_DATA_URI_PATTERN = /^\s*data:image\//i;
const ERROR_MESSAGES: Record<string, string> = { const ERROR_MESSAGE_TO_I18N: Record<string, string> = {
'Invalid Base64 string': '无效的 Base64 字符串', 'Invalid Base64 string': '无效的 Base64 字符串',
'Input appears to be binary data (e.g. an image). Please use the Image tab instead.': 'Input appears to be binary data (e.g. an image). Please use the Image tab instead.':
'检测到二进制数据(如图像),请使用图像选项卡', '检测到二进制数据(如图像),请使用图像选项卡',
@@ -30,7 +30,7 @@ export function useTextMode() {
useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData }); useContextMenuData({ featureKey: 'base64Converter', onData: handleContextMenuData });
const conversion = useMemo(() => { const conversionPipeline = useMemo(() => {
const trimmed = debouncedInput.trim(); const trimmed = debouncedInput.trim();
if (!trimmed) return { output: '', error: null }; if (!trimmed) return { output: '', error: null };
@@ -38,26 +38,31 @@ export function useTextMode() {
if (direction === 'encode') { if (direction === 'encode') {
const result = textToBase64(debouncedInput); const result = textToBase64(debouncedInput);
return { output: result.output, error: null }; return { output: result.output, error: null };
} else {
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
} }
const decoded = base64ToText(trimmed);
return { output: decoded, error: null };
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : ''; const message = e instanceof Error ? e.message : '';
const i18nKey = ERROR_MESSAGE_TO_I18N[message];
return { return {
output: '', output: '',
error: ERROR_MESSAGES[message] || message || '转换失败', error: i18nKey || message || '转换失败',
}; };
} }
}, [debouncedInput, direction]); }, [debouncedInput, direction]);
const output = conversion.output; const output = conversionPipeline.output;
const error = conversion.error; const error = conversionPipeline.error;
const placeholder = const placeholder =
direction === 'encode' ? '输入需要编码为 Base64 的文本...' : '输入需要解码的 Base64 字符串...'; direction === 'encode' ? '输入需要编码为 Base64 的文本...' : '输入需要解码的 Base64 字符串...';
const outputLabel = direction === 'encode' ? 'Base64 编码结果' : '解码文本结果'; const outputLabel = direction === 'encode' ? 'Base64 编码结果' : '解码文本结果';
const showImageHint = direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input); const showImageHint = useMemo(
() => direction === 'decode' && IMAGE_DATA_URI_PATTERN.test(input),
[direction, input],
);
const handleDirectionChange = (value: 'encode' | 'decode') => { const handleDirectionChange = (value: 'encode' | 'decode') => {
if (value === direction) return; if (value === direction) return;
+7 -2
View File
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { resolveDashboardFeatures, type DashboardFeatureItem } from '../dashboardFeatures'; import { getFeatureByKey } from '@/config/features';
import type { DashboardFeatureItem } from '../useDashboard';
import Index from '../index'; import Index from '../index';
const mockNavigateTo = vi.fn(); const mockNavigateTo = vi.fn();
@@ -12,7 +13,11 @@ vi.mock('../useDashboard', () => ({
})); }));
function buildFeatureItem(key: 'timestamp' | 'jwt'): DashboardFeatureItem { function buildFeatureItem(key: 'timestamp' | 'jwt'): DashboardFeatureItem {
return resolveDashboardFeatures([key], [key])[0]!; const feature = getFeatureByKey(key)!;
return {
key,
feature: feature as DashboardFeatureItem['feature'],
};
} }
describe('Dashboard 页面', () => { describe('Dashboard 页面', () => {
+4
View File
@@ -14,6 +14,10 @@ function isDashboardFeature(feature: FeatureConfig): feature is DashboardFeature
return feature.icon != null; return feature.icon != null;
} }
/**
* 将页面 key 列表解析为 Dashboard 可展示的工具项。
* 过滤条件:在 visiblePages 中且 feature 配置了 icon。
*/
export function resolveDashboardFeatures( export function resolveDashboardFeatures(
keys: PageType[], keys: PageType[],
visiblePages: PageType[], visiblePages: PageType[],
+27 -8
View File
@@ -1,14 +1,15 @@
import { cn } from '@/lib/utils';
import { useDashboard } from './useDashboard'; import { useDashboard } from './useDashboard';
export default function Index() { export default function Index() {
const { visibleFeatures, recentFeatures, showRecent, navigateTo } = useDashboard(); const { visibleFeatures, recentFeatures, showRecent, navigateTo } = useDashboard();
return ( return (
<div className="flex flex-col gap-4 p-3.5 w-full h-auto select-none"> <div className={cn('flex flex-col gap-4 p-3.5 w-full h-auto select-none')}>
{showRecent && ( {showRecent && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
使 {'最近使用'}
</h3> </h3>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{recentFeatures.map(({ key, feature }) => { {recentFeatures.map(({ key, feature }) => {
@@ -18,7 +19,12 @@ export default function Index() {
key={key} key={key}
type="button" type="button"
onClick={() => navigateTo(key)} onClick={() => navigateTo(key)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border border-border/60 bg-card text-card-foreground hover:bg-muted/40 hover:border-primary/30 transition-colors cursor-pointer" className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium',
'border border-border/60 bg-card text-card-foreground',
'hover:bg-muted/40 hover:border-primary/30',
'transition-colors cursor-pointer',
)}
> >
<IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" /> <IconComponent className="h-3.5 w-3.5 text-muted-foreground/70" />
{feature.label} {feature.label}
@@ -31,12 +37,14 @@ export default function Index() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider"> <h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
{'全部工具'}
</h3> </h3>
{visibleFeatures.length === 0 ? ( {visibleFeatures.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"></p> <p className="text-sm text-muted-foreground py-4 text-center">{'没有可用的工具'}</p>
) : ( ) : (
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2"> <div
className={cn('grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2')}
>
{visibleFeatures.map(({ key, feature }) => { {visibleFeatures.map(({ key, feature }) => {
const IconComponent = feature.icon; const IconComponent = feature.icon;
return ( return (
@@ -44,9 +52,20 @@ export default function Index() {
key={key} key={key}
type="button" type="button"
onClick={() => navigateTo(key)} onClick={() => navigateTo(key)}
className="group flex flex-col items-center justify-center gap-1.5 py-3 px-2 rounded-xl border border-border/50 bg-card hover:bg-muted/40 hover:border-primary/30 transition-colors cursor-pointer" className={cn(
'group flex flex-col items-center justify-center gap-1.5',
'py-3 px-2 rounded-xl border border-border/50 bg-card',
'hover:bg-muted/40 hover:border-primary/30',
'transition-colors cursor-pointer',
)}
> >
<IconComponent className="h-5 w-5 text-muted-foreground/70 group-hover:text-foreground transition-colors" /> <IconComponent
className={cn(
'h-5 w-5 text-muted-foreground/70',
'group-hover:text-foreground',
'transition-colors',
)}
/>
<span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors"> <span className="text-[11px] font-medium text-muted-foreground/80 group-hover:text-foreground leading-tight text-center truncate w-full transition-colors">
{feature.label} {feature.label}
</span> </span>
+2
View File
@@ -3,6 +3,8 @@ import { useRouter } from '@/providers/RouterProvider';
import type { PageType } from '@/types/storage'; import type { PageType } from '@/types/storage';
import { resolveDashboardFeatures, type DashboardFeatureItem } from './dashboardFeatures'; import { resolveDashboardFeatures, type DashboardFeatureItem } from './dashboardFeatures';
export type { DashboardFeatureItem } from './dashboardFeatures';
export interface UseDashboardReturn { export interface UseDashboardReturn {
visibleFeatures: DashboardFeatureItem[]; visibleFeatures: DashboardFeatureItem[];
recentFeatures: DashboardFeatureItem[]; recentFeatures: DashboardFeatureItem[];
@@ -5,6 +5,7 @@ import Index from '../index';
describe('JsonTools 页面', () => { describe('JsonTools 页面', () => {
it('应该渲染模式切换按钮', () => { it('应该渲染模式切换按钮', () => {
render(<Index />); render(<Index />);
// 基本渲染测试:页面含多个模式切换按钮,应至少渲染一个
expect(screen.getAllByRole('button').length).toBeGreaterThan(0); expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
}); });
}); });
@@ -30,7 +30,7 @@ export default function DiffNavigator({
)} )}
{...props} {...props}
> >
<span className="text-xs font-semibold text-muted-foreground/90"></span> <span className="text-xs font-semibold text-muted-foreground/90">{'无差异'}</span>
</div> </div>
); );
} }
@@ -43,10 +43,11 @@ export default function DiffNavigator({
)} )}
{...props} {...props}
> >
{/* 上一处差异按钮 */}
<button <button
type="button" type="button"
disabled={isFirst} disabled={isFirst}
aria-label="上一个" aria-label={'上一个'}
onClick={onPrev} onClick={onPrev}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -62,10 +63,11 @@ export default function DiffNavigator({
{total} {total}
</span> </span>
{/* 下一处差异按钮 */}
<button <button
type="button" type="button"
disabled={isLast} disabled={isLast}
aria-label="下一个" aria-label={'下一个'}
onClick={onNext} onClick={onNext}
className={cn( className={cn(
'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', 'p-1 rounded-md text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -23,11 +23,11 @@ export default function DiffResult({
{...props} {...props}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SectionLabel text="原始 JSON" /> <SectionLabel text={'原始 JSON'} />
<JsonTree node={result.root} side="left" activePath={activePath} /> <JsonTree node={result.root} side="left" activePath={activePath} />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SectionLabel text="目标 JSON" /> <SectionLabel text={'目标 JSON'} />
<JsonTree node={result.root} side="right" activePath={activePath} /> <JsonTree node={result.root} side="right" activePath={activePath} />
</div> </div>
</div> </div>
@@ -133,6 +133,7 @@ const UnifiedView = ({ node, depth, activePath }: UnifiedViewProps) => {
); );
} }
// 容器节点整块渲染处理
if (node.type === 'added') { if (node.type === 'added') {
return ( return (
<UnifiedRow <UnifiedRow
@@ -28,12 +28,12 @@ const CONVERT_LABELS: Record<
}; };
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> { interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
mode: string; translationPrefix: string;
convertFunction: ConvertFunction; convertFunction: ConvertFunction;
} }
export default function JsonConvertSection({ export default function JsonConvertSection({
mode, translationPrefix,
convertFunction, convertFunction,
className, className,
...props ...props
@@ -41,8 +41,10 @@ export default function JsonConvertSection({
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState('');
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml; const pk = translationPrefix;
const labels = CONVERT_LABELS[pk] || CONVERT_LABELS.yaml;
// Debounce input
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedInput(input); setDebouncedInput(input);
@@ -54,25 +56,33 @@ export default function JsonConvertSection({
return validateJson(debouncedInput); return validateJson(debouncedInput);
}, [debouncedInput]); }, [debouncedInput]);
const { result, runtimeError } = useMemo((): { const conversionPipeline = useMemo(() => {
result: ConvertResult | null;
runtimeError: string | null;
} => {
const trimmed = debouncedInput.trim(); const trimmed = debouncedInput.trim();
if (!trimmed || error) return { result: null, runtimeError: null }; if (!trimmed || error) return null;
try { try {
return { result: convertFunction(debouncedInput), runtimeError: null }; return convertFunction(debouncedInput);
} catch (e) { } catch (e) {
// 捕获可能从外部转换器(如 YAML.stringify)中抛出的底层异常
return { return {
result: null, isRuntimeError: true,
runtimeError: e instanceof Error ? e.message : String(e), errorMessage: e instanceof Error ? e.message : String(e),
}; };
} }
}, [debouncedInput, error, convertFunction]); }, [debouncedInput, error, convertFunction]);
const runtimeError =
conversionPipeline && 'isRuntimeError' in conversionPipeline
? conversionPipeline.errorMessage
: null;
const result =
conversionPipeline && !('isRuntimeError' in conversionPipeline)
? (conversionPipeline as ConvertResult)
: null;
return ( return (
<div className={cn('w-full flex flex-col gap-4', className)} {...props}> <div className={cn('w-full flex flex-col gap-4', className)} {...props}>
{/* 输入区 */}
<TextInputArea <TextInputArea
placeholder={labels.inputPlaceholder} placeholder={labels.inputPlaceholder}
value={input} value={input}
@@ -85,7 +95,8 @@ export default function JsonConvertSection({
onClear={() => setInput('')} onClear={() => setInput('')}
/> />
{result?.output ? ( {/* Result display */}
{result && result.output ? (
<JsonResultPanel <JsonResultPanel
title={labels.outputLabel} title={labels.outputLabel}
content={result.output} content={result.output}
@@ -18,6 +18,7 @@ export default function JsonFormatSection() {
const [indentSize, setIndentSize] = useState<number>(2); const [indentSize, setIndentSize] = useState<number>(2);
const [sortKeys, setSortKeys] = useState(false); const [sortKeys, setSortKeys] = useState(false);
// Debounce input
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedInput(input); setDebouncedInput(input);
@@ -29,31 +30,40 @@ export default function JsonFormatSection() {
return validateJson(debouncedInput); return validateJson(debouncedInput);
}, [debouncedInput]); }, [debouncedInput]);
const { result, runtimeError } = useMemo((): { // Real-time formatting pipeline
result: JsonFormatResult | null; const formattedPipeline = useMemo(() => {
runtimeError: string | null;
} => {
const trimmed = debouncedInput.trim(); const trimmed = debouncedInput.trim();
if (!trimmed || error) return { result: null, runtimeError: null }; if (!trimmed || error) return null;
try { try {
const options: JsonFormatOptions = { indentSize, sortKeys }; const options: JsonFormatOptions = { indentSize, sortKeys };
return { result: formatJson(debouncedInput, options), runtimeError: null }; return formatJson(debouncedInput, options);
} catch (e) { } catch (e) {
return { return {
result: null, isRuntimeError: true,
runtimeError: e instanceof SyntaxError ? e.message : String(e), errorMessage: e instanceof SyntaxError ? e.message : String(e),
}; };
} }
}, [debouncedInput, error, indentSize, sortKeys]); }, [debouncedInput, error, indentSize, sortKeys]);
const runtimeError =
formattedPipeline && 'isRuntimeError' in formattedPipeline
? formattedPipeline.errorMessage
: null;
const result =
formattedPipeline && !('isRuntimeError' in formattedPipeline)
? (formattedPipeline as JsonFormatResult)
: null;
return ( return (
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
{/* 工具控制栏 */}
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60"> <div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
<div className="flex gap-4 items-center w-full"> <div className="flex gap-4 items-center w-full">
{/* 缩进配置区 */}
<div className="flex gap-2 items-center shrink-0 select-none"> <div className="flex gap-2 items-center shrink-0 select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
{'缩进'}
</span> </span>
<SwitchButtonGroup <SwitchButtonGroup
value={indentSize} value={indentSize}
@@ -65,6 +75,7 @@ export default function JsonFormatSection() {
<div className="h-4 w-px bg-border/60" /> <div className="h-4 w-px bg-border/60" />
{/* 键名排序区 */}
<div <div
onClick={() => setSortKeys(!sortKeys)} onClick={() => setSortKeys(!sortKeys)}
className="flex items-center gap-2 cursor-pointer select-none group py-1" className="flex items-center gap-2 cursor-pointer select-none group py-1"
@@ -80,14 +91,15 @@ export default function JsonFormatSection() {
htmlFor="sort-keys-checkbox" htmlFor="sort-keys-checkbox"
className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground" className="text-xs font-bold text-foreground/80 cursor-pointer tracking-tight group-hover:text-foreground"
> >
{'键名排序'}
</Label> </Label>
</div> </div>
</div> </div>
</div> </div>
{/* 满血版输入终端 */}
<TextInputArea <TextInputArea
placeholder="输入需要格式化的 JSON..." placeholder={'输入需要格式化的 JSON...'}
value={input} value={input}
onChange={setInput} onChange={setInput}
externalError={error || runtimeError || undefined} externalError={error || runtimeError || undefined}
@@ -98,7 +110,8 @@ export default function JsonFormatSection() {
onClear={() => setInput('')} onClear={() => setInput('')}
/> />
{result?.formatted ? ( {/* 格式化结果流面板展示 */}
{result && result.formatted ? (
<JsonResultPanel <JsonResultPanel
title="格式化结果" title="格式化结果"
content={result.formatted} content={result.formatted}
@@ -28,7 +28,7 @@ export default function JsonResultPanel({
<div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums"> <div className="hidden sm:flex gap-3 items-center font-mono text-[10px] text-muted-foreground/70 tabular-nums">
<span> <span>
:{' '} {'原始大小'}:{' '}
<span className="font-semibold text-foreground/80">{formatBytes(originalBytes)}</span> <span className="font-semibold text-foreground/80">{formatBytes(originalBytes)}</span>
</span> </span>
<span className="text-border/60">|</span> <span className="text-border/60">|</span>
+10 -2
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react'; import { ChevronDown, ChevronRight } from 'lucide-react'; // 用正统的矢量箭头平替原生的字符 '▾' '▸'
import type { DiffNode, DiffType } from '../types'; import type { DiffNode, DiffType } from '../types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -82,12 +82,14 @@ const NodeRow = React.memo(
? false ? false
: onActivePath || depth < defaultExpandDepth; : onActivePath || depth < defaultExpandDepth;
// 当激活路径精准定位到本行时,平滑滚动至容器中心
useEffect(() => { useEffect(() => {
if (activePath === node.path) { if (activePath === node.path) {
rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
}, [activePath, node.path]); }, [activePath, node.path]);
// 占位空行分支:必须加 h-[22px] 锁定绝对等高,防止两侧文本高度塌陷发生高低错位
if (!shouldRenderOnSide(node.type, side)) { if (!shouldRenderOnSide(node.type, side)) {
return ( return (
<div <div
@@ -122,6 +124,7 @@ const NodeRow = React.memo(
return ( return (
<div ref={rowRef} className="w-full flex flex-col"> <div ref={rowRef} className="w-full flex flex-col">
{/* 大容器开端行 */}
<div <div
onClick={() => setOverride(expanded ? 'closed' : 'open')} onClick={() => setOverride(expanded ? 'closed' : 'open')}
className={cn( className={cn(
@@ -132,6 +135,7 @@ const NodeRow = React.memo(
)} )}
style={indentStyle} style={indentStyle}
> >
{/* 折叠小箭头:升级为精巧的 Lucide SVG 矢量微动效 */}
<span className="w-3.5 h-3.5 flex items-center justify-center text-muted-foreground/80 shrink-0"> <span className="w-3.5 h-3.5 flex items-center justify-center text-muted-foreground/80 shrink-0">
{expanded ? ( {expanded ? (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
@@ -160,6 +164,7 @@ const NodeRow = React.memo(
)} )}
</div> </div>
{/* 容器子节点递归区 */}
{expanded && ( {expanded && (
<div className={indentClass}> <div className={indentClass}>
{node.children.map((child, idx) => ( {node.children.map((child, idx) => (
@@ -176,6 +181,7 @@ const NodeRow = React.memo(
</div> </div>
)} )}
{/* 大容器收尾行 */}
{expanded && ( {expanded && (
<div <div
className="text-muted-foreground/80 font-mono text-xs py-0.5 h-[22px] leading-relaxed" className="text-muted-foreground/80 font-mono text-xs py-0.5 h-[22px] leading-relaxed"
@@ -189,6 +195,7 @@ const NodeRow = React.memo(
); );
} }
// 叶子数据行分支
return ( return (
<div <div
ref={rowRef} ref={rowRef}
@@ -200,7 +207,7 @@ const NodeRow = React.memo(
)} )}
style={indentStyle} style={indentStyle}
> >
<span className="w-3.5 shrink-0" /> <span className="w-3.5 shrink-0" /> {/* 与上方的折叠键轴线严格对齐 */}
{!isRoot && ( {!isRoot && (
<span className="text-foreground/90 font-bold tracking-tight">{node.key}:</span> <span className="text-foreground/90 font-bold tracking-tight">{node.key}:</span>
)} )}
@@ -224,6 +231,7 @@ export default function JsonTree({
...props ...props
}: JsonTreeProps) { }: JsonTreeProps) {
return ( return (
/* 最外层承载器:统一收拢至标准的 bg-card 与等宽 tabular-nums 控制轴 */
<div <div
className={cn( className={cn(
'rounded-xl border border-border bg-card text-card-foreground font-mono text-xs shadow-sm overflow-x-auto min-h-[200px] max-h-[520px] overflow-y-auto p-2.5 tabular-nums select-text', 'rounded-xl border border-border bg-card text-card-foreground font-mono text-xs shadow-sm overflow-x-auto min-h-[200px] max-h-[520px] overflow-y-auto p-2.5 tabular-nums select-text',
+7 -7
View File
@@ -66,16 +66,16 @@ export default function Index() {
<div className="flex flex-col md:flex-row gap-4 w-full items-stretch"> <div className="flex flex-col md:flex-row gap-4 w-full items-stretch">
<JsonDiffInput <JsonDiffInput
label="原始 JSON" label={'原始 JSON'}
placeholder="输入原始 JSON..." placeholder={'输入原始 JSON...'}
value={leftInput} value={leftInput}
onChange={setLeftInput} onChange={setLeftInput}
error={leftError} error={leftError}
minRows={9} minRows={9}
/> />
<JsonDiffInput <JsonDiffInput
label="目标 JSON" label={'目标 JSON'}
placeholder="输入目标 JSON..." placeholder={'输入目标 JSON...'}
value={rightInput} value={rightInput}
onChange={setRightInput} onChange={setRightInput}
error={rightError} error={rightError}
@@ -106,11 +106,11 @@ export default function Index() {
) : pageMode === 'format' ? ( ) : pageMode === 'format' ? (
<JsonFormatSection /> <JsonFormatSection />
) : pageMode === 'yaml' ? ( ) : pageMode === 'yaml' ? (
<JsonConvertSection mode="yaml" convertFunction={yamlConvert} /> <JsonConvertSection translationPrefix="yaml" convertFunction={yamlConvert} />
) : pageMode === 'toml' ? ( ) : pageMode === 'toml' ? (
<JsonConvertSection mode="toml" convertFunction={tomlConvert} /> <JsonConvertSection translationPrefix="toml" convertFunction={tomlConvert} />
) : ( ) : (
<JsonConvertSection mode="minify" convertFunction={minifyConvert} /> <JsonConvertSection translationPrefix="minify" convertFunction={minifyConvert} />
)} )}
</div> </div>
); );
+7
View File
@@ -11,6 +11,7 @@ import type { ConvertFunction, ViewMode } from './types';
export interface UseJsonToolsReturn { export interface UseJsonToolsReturn {
pageMode: JsonToolsPageMode; pageMode: JsonToolsPageMode;
setPageMode: (mode: JsonToolsPageMode) => void; setPageMode: (mode: JsonToolsPageMode) => void;
// Diff mode state
leftInput: string; leftInput: string;
rightInput: string; rightInput: string;
setLeftInput: (val: string) => void; setLeftInput: (val: string) => void;
@@ -25,6 +26,7 @@ export interface UseJsonToolsReturn {
handlePrev: () => void; handlePrev: () => void;
handleNext: () => void; handleNext: () => void;
activePath: string | undefined; activePath: string | undefined;
// Convert functions
yamlConvert: ConvertFunction; yamlConvert: ConvertFunction;
tomlConvert: ConvertFunction; tomlConvert: ConvertFunction;
minifyConvert: ConvertFunction; minifyConvert: ConvertFunction;
@@ -33,11 +35,13 @@ export interface UseJsonToolsReturn {
export function useJsonTools(): UseJsonToolsReturn { export function useJsonTools(): UseJsonToolsReturn {
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
// Diff inputs
const [leftInput, setLeftInput] = useState(''); const [leftInput, setLeftInput] = useState('');
const [rightInput, setRightInput] = useState(''); const [rightInput, setRightInput] = useState('');
const [debouncedLeft, setDebouncedLeft] = useState(''); const [debouncedLeft, setDebouncedLeft] = useState('');
const [debouncedRight, setDebouncedRight] = useState(''); const [debouncedRight, setDebouncedRight] = useState('');
// Debounce
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedLeft(leftInput); setDebouncedLeft(leftInput);
@@ -46,6 +50,7 @@ export function useJsonTools(): UseJsonToolsReturn {
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [leftInput, rightInput]); }, [leftInput, rightInput]);
// Parse debounced inputs
const parseState = useMemo(() => { const parseState = useMemo(() => {
const invalidMsg = '无效的 JSON 格式'; const invalidMsg = '无效的 JSON 格式';
return { return {
@@ -60,6 +65,7 @@ export function useJsonTools(): UseJsonToolsReturn {
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide'); const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
const [currentDiffIndex, setCurrentDiffIndex] = useState(0); const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
// Real-time diff computation
const diffResult = useMemo(() => { const diffResult = useMemo(() => {
const { left, right } = parseState; const { left, right } = parseState;
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') { if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
@@ -82,6 +88,7 @@ export function useJsonTools(): UseJsonToolsReturn {
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined; const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
// Convert functions
const yamlConvert: ConvertFunction = useCallback((text: string) => { const yamlConvert: ConvertFunction = useCallback((text: string) => {
const r = jsonToYaml(text); const r = jsonToYaml(text);
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes }; return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
@@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react'; import { act, fireEvent, render, screen } from '@testing-library/react';
import ImageUploader from '../components/ImageUploader'; import ImageUploader from '../components/ImageUploader';
// 配置多端一致性常驻桩(WXT 规范)
const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() }; const storageOnChangedMock = { addListener: vi.fn(), removeListener: vi.fn() };
(globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).chrome = { storage: { onChanged: storageOnChangedMock } };
(globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } }; (globalThis as any).browser = { storage: { onChanged: storageOnChangedMock } };
// 模拟 URL API
const mockCreateObjectURL = vi.fn(); const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn(); const mockRevokeObjectURL = vi.fn();
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL }); Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
@@ -169,4 +171,21 @@ describe('ImageUploader 组件', () => {
expect(pasteListeners).toHaveLength(0); expect(pasteListeners).toHaveLength(0);
}); });
}); });
describe('样式测试', () => {
it('拖拽状态时应应用拖拽样式', () => {
const { container } = render(<ImageUploader {...defaultProps} dragging={true} />);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
it('有文件时应应用有文件样式', () => {
const mockFile = new File(['test'], 'test.png', { type: 'image/png' });
const { container } = render(
<ImageUploader {...defaultProps} selectedFile={mockFile} previewUrl="blob:test-url" />,
);
const dropzone = container.firstChild as HTMLElement;
expect(dropzone).toBeInTheDocument();
});
});
}); });
@@ -18,6 +18,14 @@ vi.mock('@/components/GlobalSnackbar', () => ({
}), }),
})); }));
vi.mock('@/config/features', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config/features')>();
return {
...actual,
getEntryPointType: () => 'sidepanel',
};
});
vi.mock('../components/QrCodePreview', () => ({ vi.mock('../components/QrCodePreview', () => ({
default: () => <div data-testid="qr-code-preview">QrCodePreview</div>, default: () => <div data-testid="qr-code-preview">QrCodePreview</div>,
})); }));
+12 -6
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** 文本预览的最大显示字符数 */
const TEXT_PREVIEW_MAX_LENGTH = 80; const TEXT_PREVIEW_MAX_LENGTH = 80;
export default function GeneratePanel() { export default function GeneratePanel() {
@@ -21,11 +22,13 @@ export default function GeneratePanel() {
const isInputStep = generatorState.step === 'input'; const isInputStep = generatorState.step === 'input';
const hasText = generatorState.textToEncode.trim().length > 0; const hasText = generatorState.textToEncode.trim().length > 0;
/** 截断文本用于预览显示 */
const getTruncatedText = (text: string) => { const getTruncatedText = (text: string) => {
if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text; if (text.length <= TEXT_PREVIEW_MAX_LENGTH) return text;
return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...'; return text.slice(0, TEXT_PREVIEW_MAX_LENGTH) + '...';
}; };
// 输入态:只显示输入框和生成按钮
if (isInputStep) { if (isInputStep) {
return ( return (
<div className="w-full select-none p-0.5"> <div className="w-full select-none p-0.5">
@@ -37,13 +40,13 @@ export default function GeneratePanel() {
> >
<div className="flex flex-col space-y-2.5"> <div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
URL {'输入 URL 或文本'}
</Label> </Label>
<TextInputArea <TextInputArea
value={generatorState.textToEncode} value={generatorState.textToEncode}
onChange={setTextToEncode} onChange={setTextToEncode}
placeholder="请输入 URL 或文本内容,将自动生成二维码" placeholder={'请输入 URL 或文本内容,将自动生成二维码'}
showCount={true} showCount={true}
showClear={true} showClear={true}
allowCopy={false} allowCopy={false}
@@ -61,12 +64,12 @@ export default function GeneratePanel() {
{generatorState.generating ? ( {generatorState.generating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
... {'生成中...'}
</> </>
) : ( ) : (
<> <>
<QrCode className="w-4 h-4 mr-2" /> <QrCode className="w-4 h-4 mr-2" />
{'生成二维码'}
</> </>
)} )}
</Button> </Button>
@@ -76,16 +79,18 @@ export default function GeneratePanel() {
); );
} }
// 预览态:显示文本预览 + 二维码(紧凑布局,适配popup窗口)
return ( return (
<div className="w-full select-none p-0.5 flex flex-col"> <div className="w-full select-none p-0.5 flex flex-col">
{/* 文本预览区域(固定高度,单行显示) */}
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'原始文本'}
</Label> </Label>
<Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs"> <Button variant="ghost" size="sm" onClick={backToEdit} className="h-6 px-2 text-xs">
<Pencil className="w-3 h-3 mr-1" /> <Pencil className="w-3 h-3 mr-1" />
{'编辑'}
</Button> </Button>
</div> </div>
<div <div
@@ -96,6 +101,7 @@ export default function GeneratePanel() {
</div> </div>
</div> </div>
{/* 二维码预览区域(自适应剩余空间) */}
<QrCodePreview <QrCodePreview
qrCodeDataUrl={generatorState.qrCodeDataUrl} qrCodeDataUrl={generatorState.qrCodeDataUrl}
onDownload={downloadQrCode} onDownload={downloadQrCode}
+25 -9
View File
@@ -1,14 +1,21 @@
import { useCallback, useRef } from 'react';
import { Image, X } from 'lucide-react'; import { Image, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
interface ImageUploaderProps { interface ImageUploaderProps {
/** 选中的文件 */
selectedFile: File | null; selectedFile: File | null;
/** 文件变更回调 */
onFileChange: (file: File) => void; onFileChange: (file: File) => void;
/** 清除文件回调 */
onClearFile: () => void; onClearFile: () => void;
/** 文件预览 URL */
previewUrl: string; previewUrl: string;
/** 是否正在拖拽 */
dragging: boolean; dragging: boolean;
/** 拖拽状态变更回调 */
onDraggingChange: (dragging: boolean) => void; onDraggingChange: (dragging: boolean) => void;
} }
@@ -20,18 +27,26 @@ const ImageUploader = ({
dragging, dragging,
onDraggingChange, onDraggingChange,
}: ImageUploaderProps) => { }: ImageUploaderProps) => {
const handleClearFile = () => { const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = useCallback(
(file: File) => {
onFileChange(file);
},
[onFileChange],
);
const handleClearFile = useCallback(() => {
if (previewUrl) { if (previewUrl) {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
} }
onClearFile(); onClearFile();
toast.success('图片已清除'); toast.success('图片已清除');
}; }, [previewUrl, onClearFile]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (e.target.files && e.target.files.length > 0) {
if (file) { handleFileChange(e.target.files[0]);
onFileChange(file);
} }
}; };
@@ -49,7 +64,7 @@ const ImageUploader = ({
onDraggingChange(false); onDraggingChange(false);
const droppedFile = e.dataTransfer.files?.[0]; const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) { if (droppedFile) {
onFileChange(droppedFile); handleFileChange(droppedFile);
} }
}; };
@@ -67,6 +82,7 @@ const ImageUploader = ({
onDrop={handleDrop} onDrop={handleDrop}
> >
<input <input
ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*"
onChange={handleInputChange} onChange={handleInputChange}
@@ -97,7 +113,7 @@ const ImageUploader = ({
</Button> </Button>
</div> </div>
<span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span> <span className="block text-sm text-muted-foreground mt-2">{selectedFile.name}</span>
<span className="block text-xs text-muted-foreground"></span> <span className="block text-xs text-muted-foreground">{'点击更换图片'}</span>
</div> </div>
) : ( ) : (
<> <>
@@ -106,10 +122,10 @@ const ImageUploader = ({
className="w-12 h-12 text-muted-foreground mx-auto mb-2" className="w-12 h-12 text-muted-foreground mx-auto mb-2"
/> />
<span className="block text-sm text-muted-foreground mb-1"> <span className="block text-sm text-muted-foreground mb-1">
{'点击、拖拽或粘贴上传二维码图片'}
</span> </span>
<span className="block text-xs text-muted-foreground"> <span className="block text-xs text-muted-foreground">
PNGJPGWEBPBase64 {'支持 PNG、JPG、WEBP、Base64 格式'}
</span> </span>
</> </>
)} )}
+9 -4
View File
@@ -13,6 +13,7 @@ export default function ParsePanel() {
const hasFile = parserState.selectedFile !== null; const hasFile = parserState.selectedFile !== null;
// 全局粘贴事件监听
const handlePaste = useCallback( const handlePaste = useCallback(
async (e: ClipboardEvent) => { async (e: ClipboardEvent) => {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
@@ -55,6 +56,7 @@ export default function ParsePanel() {
}; };
}, [handlePaste]); }, [handlePaste]);
// 未上传图片:只显示上传区域
if (!hasFile) { if (!hasFile) {
return ( return (
<div className="w-full select-none p-0.5"> <div className="w-full select-none p-0.5">
@@ -70,16 +72,18 @@ export default function ParsePanel() {
); );
} }
// 已上传图片:显示图片预览 + 解析结果
return ( return (
<div className="w-full select-none p-0.5 flex flex-col"> <div className="w-full select-none p-0.5 flex flex-col">
{/* 图片预览区域(小图预览) */}
<div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3"> <div className="border border-border rounded-xl bg-card text-card-foreground shadow-sm p-3 mb-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider select-none pl-0.5">
{'已上传图片'}
</Label> </Label>
<Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs"> <Button variant="ghost" size="sm" onClick={handleClearFile} className="h-6 px-2 text-xs">
<RefreshCw className="w-3 h-3 mr-1" /> <RefreshCw className="w-3 h-3 mr-1" />
{'重新上传'}
</Button> </Button>
</div> </div>
<div className="flex items-center gap-3 bg-muted/50 rounded-md p-2"> <div className="flex items-center gap-3 bg-muted/50 rounded-md p-2">
@@ -91,12 +95,13 @@ export default function ParsePanel() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p> <p className="text-sm text-foreground truncate">{parserState.selectedFile?.name}</p>
{parserState.parsing && ( {parserState.parsing && (
<p className="text-xs text-primary animate-pulse mt-1">...</p> <p className="text-xs text-primary animate-pulse mt-1">{'解析中...'}</p>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* 解析结果区域 */}
<div <div
className={cn( className={cn(
'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4', 'border border-border rounded-xl bg-card text-card-foreground shadow-sm flex flex-col p-4',
@@ -105,7 +110,7 @@ export default function ParsePanel() {
> >
<div className="flex flex-col space-y-2.5"> <div className="flex flex-col space-y-2.5">
<Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider pl-0.5">
{'解析结果'}
</Label> </Label>
<TextInputArea <TextInputArea
+14 -3
View File
@@ -4,10 +4,15 @@ import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
// 继承原生 HTML Div 属性,方便外部无缝扩充类名或监听事件
interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> { interface QrCodePreviewProps extends React.HTMLAttributes<HTMLDivElement> {
/** 二维码 Data URL */
qrCodeDataUrl: string; qrCodeDataUrl: string;
/** 下载回调 */
onDownload: () => void; onDownload: () => void;
/** 复制回调 */
onCopy: () => void; onCopy: () => void;
/** 自定义占位文本,用于空状态提示 */
placeholderText?: string; placeholderText?: string;
} }
@@ -19,6 +24,7 @@ const QrCodePreview = ({
className, className,
...props ...props
}: QrCodePreviewProps) => { }: QrCodePreviewProps) => {
// 空状态下的虚线骨架屏
if (!qrCodeDataUrl) { if (!qrCodeDataUrl) {
return ( return (
<EmptyPlaceholder <EmptyPlaceholder
@@ -40,23 +46,28 @@ const QrCodePreview = ({
{...props} {...props}
> >
<div className="flex flex-col items-center w-full max-w-xs"> <div className="flex flex-col items-center w-full max-w-xs">
{/*
二维码容器适配:
在暗黑模式下,纯黑白的二维码如果直接暴露在暗色背景下,会导致手机摄像头极难识别。
通过裹一层 bg-white 和 p-2,确保黑白对比度绝对安全,同时加入 shadow 增强卡片感。
*/}
<div className="p-2 bg-white rounded-lg shadow-sm border border-border/40"> <div className="p-2 bg-white rounded-lg shadow-sm border border-border/40">
<img <img
src={qrCodeDataUrl} src={qrCodeDataUrl}
alt="QR Code Preview" alt="QR Code Preview"
className="w-48 h-48 max-w-full object-contain block fade-in-300" className="w-48 h-48 max-w-full object-contain block animate-in fade-in duration-300"
/> />
</div> </div>
<div className="flex w-full gap-2 mt-3"> <div className="flex w-full gap-2 mt-3">
<Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8"> <Button variant="outline" size="sm" onClick={onDownload} className="flex-1 h-8">
<Download className="w-3.5 h-3.5 text-muted-foreground" /> <Download className="w-3.5 h-3.5 text-muted-foreground" />
<span className="truncate text-xs"></span> <span className="truncate text-xs">{'下载二维码'}</span>
</Button> </Button>
<Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8"> <Button variant="default" size="sm" onClick={onCopy} className="flex-1 h-8">
<Copy className="w-3.5 h-3.5" /> <Copy className="w-3.5 h-3.5" />
<span className="truncate text-xs"></span> <span className="truncate text-xs">{'复制二维码'}</span>
</Button> </Button>
</div> </div>
</div> </div>
+9 -12
View File
@@ -12,26 +12,32 @@ function isUrl(text: string): boolean {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return false; if (!trimmed) return false;
// 检查是否以 http:// 或 https:// 开头
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return true; return true;
} }
// 检查是否为域名格式(包含.且不以特殊字符开头)
const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/; const domainPattern = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+([/:].*)?$/;
return domainPattern.test(trimmed); return domainPattern.test(trimmed);
} }
/** 检测URL是否为图片格式 */
function isImageUrl(url: string): boolean { function isImageUrl(url: string): boolean {
const trimmedUrl = url.trim().toLowerCase(); const trimmedUrl = url.trim().toLowerCase();
// 检查是否为 data:image 格式
if (trimmedUrl.startsWith('data:image/')) { if (trimmedUrl.startsWith('data:image/')) {
return true; return true;
} }
// 检查是否包含图片扩展名
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico']; const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico'];
if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) { if (imageExtensions.some((ext) => trimmedUrl.includes(ext))) {
return true; return true;
} }
// 检查URL路径中是否包含图片相关关键词
const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/']; const imageKeywords = ['/image/', '/img/', '/photo/', '/pic/', '/upload/'];
if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) { if (imageKeywords.some((keyword) => trimmedUrl.includes(keyword))) {
return true; return true;
@@ -40,6 +46,7 @@ function isImageUrl(url: string): boolean {
return false; return false;
} }
/** 生成二维码的核心逻辑 */
function generateQrCodeDataUrl(text: string): string { function generateQrCodeDataUrl(text: string): string {
const trimmedText = text.trim(); const trimmedText = text.trim();
if (!trimmedText) return ''; if (!trimmedText) return '';
@@ -234,17 +241,7 @@ export function useQrCode(): QrCodeContextValue {
const qrCodeDataUrl = generateQrCodeDataUrl(payload); const qrCodeDataUrl = generateQrCodeDataUrl(payload);
if (qrCodeDataUrl) { if (!qrCodeDataUrl) {
setGeneratorState((prev) => ({
...prev,
step: 'preview',
textToEncode: payload,
savedText: payload.trim(),
qrCodeDataUrl,
generating: false,
inputError: '',
}));
} else {
setGeneratorState((prev) => ({ setGeneratorState((prev) => ({
...prev, ...prev,
step: 'input', step: 'input',
@@ -331,7 +328,7 @@ export function useQrCode(): QrCodeContextValue {
}); });
parseQrCode(file).catch((err) => { parseQrCode(file).catch((err) => {
console.error('解析二维码失败:', err); console.error('Parser standalone task thread exploded:', err);
}); });
}, },
[parseQrCode], [parseQrCode],
+5 -1
View File
@@ -6,6 +6,10 @@ export type RestorerStatus = 'unsupported' | 'locked' | 'unlocked';
export const CARD_CLASS = export const CARD_CLASS =
'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden space-y-3'; 'w-full p-4 rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden space-y-3';
export const LOADING_TEXT = '正在加载...';
export const DOMAIN_LABEL = '当前域名';
interface StatusConfig { interface StatusConfig {
badgeVariant: 'default' | 'secondary' | 'destructive'; badgeVariant: 'default' | 'secondary' | 'destructive';
badgeClassName: string; badgeClassName: string;
@@ -46,7 +50,7 @@ export const STATUS_CONFIG: Record<RestorerStatus, StatusConfig> = {
badgeClassName: 'gap-1 bg-green-600 hover:bg-green-700 shrink-0', badgeClassName: 'gap-1 bg-green-600 hover:bg-green-700 shrink-0',
badgeIcon: ShieldCheck, badgeIcon: ShieldCheck,
badgeLabel: '已解锁', badgeLabel: '已解锁',
description: '当前网站右键菜单已临时解锁。刷新页面后需要重新解锁。', description: '点击下方按钮,为当前网站临时解锁右键菜单。刷新页面后需要重新解锁。',
buttonIcon: MousePointerClick, buttonIcon: MousePointerClick,
buttonLabel: '右键已解锁', buttonLabel: '右键已解锁',
buttonDisabled: true, buttonDisabled: true,
+5 -5
View File
@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { CARD_CLASS, STATUS_CONFIG } from './constants'; import { CARD_CLASS, DOMAIN_LABEL, LOADING_TEXT, STATUS_CONFIG } from './constants';
import { useRightClickRestorer } from './useRightClickRestorer'; import { useRightClickRestorer } from './useRightClickRestorer';
export default function Index() { export default function Index() {
@@ -13,7 +13,7 @@ export default function Index() {
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80 animate-spin" /> <Loader2 className="h-6 w-6 text-muted-foreground/80 animate-spin" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
... {LOADING_TEXT}
</span> </span>
</div> </div>
); );
@@ -24,9 +24,9 @@ export default function Index() {
const ButtonIcon = config.buttonIcon; const ButtonIcon = config.buttonIcon;
return ( return (
<div className="p-4 w-full"> <div className="p-4 w-full flex flex-col space-y-4">
<div className={CARD_CLASS}> <div className={CARD_CLASS}>
<Label className="text-sm font-medium"></Label> <Label className="text-sm font-medium">{DOMAIN_LABEL}</Label>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1"> <code className="text-sm bg-muted px-2 py-1 rounded truncate min-w-0 flex-1">
{domain || '—'} {domain || '—'}
@@ -42,7 +42,7 @@ export default function Index() {
className="w-full gap-2" className="w-full gap-2"
disabled={config.buttonDisabled} disabled={config.buttonDisabled}
variant={config.buttonVariant} variant={config.buttonVariant}
onClick={() => void unlock()} onClick={config.buttonDisabled ? undefined : () => void unlock()}
> >
<ButtonIcon className="h-4 w-4" /> <ButtonIcon className="h-4 w-4" />
{config.buttonLabel} {config.buttonLabel}
@@ -3,6 +3,11 @@ 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';
function deriveStatus(isUnsupported: boolean, isUnlocked: boolean): RestorerStatus {
if (isUnsupported) return 'unsupported';
return isUnlocked ? 'unlocked' : 'locked';
}
export interface UseRightClickRestorerReturn { export interface UseRightClickRestorerReturn {
domain: string; domain: string;
isLoading: boolean; isLoading: boolean;
@@ -11,9 +16,10 @@ export interface UseRightClickRestorerReturn {
} }
export function useRightClickRestorer(): UseRightClickRestorerReturn { export function useRightClickRestorer(): UseRightClickRestorerReturn {
const [domain, setDomain] = useState(''); const [domain, setDomain] = useState<string>('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [status, setStatus] = useState<RestorerStatus>('locked'); const [isUnlocked, setIsUnlocked] = useState<boolean>(false);
const [isUnsupported, setIsUnsupported] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -24,13 +30,13 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
setDomain(url ? new URL(url).hostname : ''); setDomain(url ? new URL(url).hostname : '');
if (isUnsupportedPageUrl(url)) { if (isUnsupportedPageUrl(url)) {
setStatus('unsupported'); setIsUnsupported(true);
return; return;
} }
const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS); const response = await sendMessageToContent(MessageAction.QUERY_RIGHT_CLICK_STATUS);
if (response?.success) { if (response?.success) {
setStatus(response.restored ? 'unlocked' : 'locked'); setIsUnlocked(response.restored);
} }
} catch (err) { } catch (err) {
console.error('[RightClickRestorer] Failed to load state:', err); console.error('[RightClickRestorer] Failed to load state:', err);
@@ -43,12 +49,12 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
}, []); }, []);
async function unlock() { async function unlock() {
if (status === 'unsupported') return; if (isUnsupported) return;
try { try {
const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK); const response = await sendMessageToContent(MessageAction.RESTORE_RIGHT_CLICK);
if (response?.success) { if (response?.success) {
setStatus(response.restored ? 'unlocked' : 'locked'); setIsUnlocked(response.restored);
} }
} catch (err) { } catch (err) {
console.error('[RightClickRestorer] Failed to unlock:', err); console.error('[RightClickRestorer] Failed to unlock:', err);
@@ -58,7 +64,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn {
return { return {
domain, domain,
isLoading, isLoading,
status, status: deriveStatus(isUnsupported, isUnlocked),
unlock, unlock,
}; };
} }
@@ -5,6 +5,7 @@ import Index from '../index';
import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner'; import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Mock the chrome APIs
vi.mock('@/utils/chromeStorage', () => ({ vi.mock('@/utils/chromeStorage', () => ({
storageUtil: { storageUtil: {
get: vi.fn().mockResolvedValue(null), get: vi.fn().mockResolvedValue(null),
@@ -126,34 +127,7 @@ describe('StorageCleaner 页面', () => {
await waitFor(() => { await waitFor(() => {
expect(clearStorage).not.toHaveBeenCalled(); expect(clearStorage).not.toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith('当前页面已变更,请等待数据刷新后再清理'); expect(toast.warning).toHaveBeenCalledWith('当前标签页已切换,请等待数据刷新后再清理');
});
});
it('同一标签页 URL 变更后、数据刷新完成前不应执行清理', async () => {
let currentUrl = 'https://a.example.com';
vi.mocked(getCurrentTab).mockImplementation(
async () =>
({
id: 1,
url: currentUrl,
}) as any,
);
render(<Index />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /立即清理/ })).not.toBeDisabled();
});
currentUrl = 'https://b.example.com';
fireEvent.click(screen.getByRole('button', { name: /立即清理/ }));
fireEvent.click(screen.getByRole('button', { name: /确认清理/ }));
await waitFor(() => {
expect(clearStorage).not.toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith('当前页面已变更,请等待数据刷新后再清理');
}); });
}); });
}); });
@@ -1,3 +1,18 @@
/**
* 自动刷新开关行
*
* 用于 StorageCleaner 页面的操作区域内(非独立卡片),
* 控制清理后是否自动刷新页面。左侧为标签文本,右侧为 shadcn/ui Switch 开关。
*
* @example
* ```tsx
* <AutoRefreshToggle
* reloadAfterClean={reloadAfterClean}
* onChange={(checked) => setReloadAfterClean(checked)}
* />
* ```
*/
import React from 'react'; import React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -7,10 +22,20 @@ export interface AutoRefreshToggleProps extends Omit<
React.HTMLAttributes<HTMLDivElement>, React.HTMLAttributes<HTMLDivElement>,
'onChange' 'onChange'
> { > {
/** 开关的当前状态(受控) */
reloadAfterClean: boolean; reloadAfterClean: boolean;
/** 状态变化时的回调函数 */
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
} }
/**
* 自动刷新开关
*
* @param reloadAfterClean - 当前开关状态
* @param onChange - 状态变化回调,接收新的布尔值
* @param className - 额外的 CSS 类名,用于覆盖或扩展样式
* @param props - 透传给外层 div 的其他 HTML 属性
*/
export default function AutoRefreshToggle({ export default function AutoRefreshToggle({
reloadAfterClean, reloadAfterClean,
onChange, onChange,
@@ -29,7 +54,7 @@ export default function AutoRefreshToggle({
htmlFor="auto-refresh-switch" htmlFor="auto-refresh-switch"
className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase" className="text-xs font-bold text-muted-foreground/90 cursor-pointer select-none tracking-wide uppercase"
> >
{'清理后自动刷新页面'}
</Label> </Label>
<Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} /> <Switch id="auto-refresh-switch" checked={reloadAfterClean} onCheckedChange={onChange} />
@@ -24,7 +24,7 @@ export default function ErrorDisplay({ error, className, ...props }: ErrorDispla
</p> </p>
<p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2"> <p className="text-xs font-medium leading-relaxed text-muted-foreground/90 px-2">
{'存储清理功能仅适用于标准网页'}
</p> </p>
</div> </div>
</div> </div>
@@ -2,11 +2,11 @@ import React from 'react';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { StorageSizeInfo } from '../useStorageCleaner'; import type { StorageSizeInfo } from '../useStorageCleaner';
import { CLEAN_OPTION_KEYS, OPTION_LABELS } from '../constants'; import { OPTION_LABELS } from '../constants';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> { interface OptionItemProps extends React.HTMLAttributes<HTMLDivElement> {
labelKey: (typeof CLEAN_OPTION_KEYS)[number]; labelKey: string;
checked: boolean; checked: boolean;
sizeInfo?: StorageSizeInfo; sizeInfo?: StorageSizeInfo;
onChange: () => void; onChange: () => void;
@@ -22,7 +22,8 @@ export default function OptionItem({
}: OptionItemProps) { }: OptionItemProps) {
const sizeValue = sizeInfo?.value; const sizeValue = sizeInfo?.value;
const isCount = sizeInfo?.displayType === 'count'; const isCount = sizeInfo?.displayType === 'count';
const label = OPTION_LABELS[labelKey]; const label =
labelKey in OPTION_LABELS ? OPTION_LABELS[labelKey as keyof typeof OPTION_LABELS] : labelKey;
return ( return (
<div <div
@@ -57,7 +58,7 @@ export default function OptionItem({
</span> </span>
) : ( ) : (
<span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic"> <span className="block text-[10px] font-medium text-muted-foreground/50 mt-0.5 italic">
{'无数据'}
</span> </span>
)} )}
</div> </div>
@@ -10,7 +10,7 @@ import {
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import type { StorageCleanerOptions } from '@/types/storage'; import type { StorageCleanerOptions } from '@/types/storage';
import { OPTION_LABELS, CLEAN_OPTION_KEYS } from '../constants'; import { OPTION_LABELS } from '../constants';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface StorageCleanerConfirmProps { export interface StorageCleanerConfirmProps {
@@ -26,9 +26,11 @@ export function StorageCleanerConfirm({
onConfirm, onConfirm,
options, options,
}: StorageCleanerConfirmProps) { }: StorageCleanerConfirmProps) {
const selectedOptions = CLEAN_OPTION_KEYS.filter((key) => options[key]).map( const selectedOptions = Object.entries(options)
(key) => OPTION_LABELS[key], .filter(([_, value]) => value)
); .map(([key]) =>
key in OPTION_LABELS ? OPTION_LABELS[key as keyof typeof OPTION_LABELS] : key,
);
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -40,14 +42,14 @@ export function StorageCleanerConfirm({
{/* 头部标题区域 */} {/* 头部标题区域 */}
<DialogHeader className="pt-1"> <DialogHeader className="pt-1">
<DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground"> <DialogTitle className="text-center text-lg font-bold tracking-tight text-foreground">
{'确认清理数据?'}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
{/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */} {/* 内容主体:限制最大宽度,防止内部元素在大分辨率下被横向拉得太松散 */}
<div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto"> <div className="text-center py-4 flex flex-col items-center w-full max-w-[280px] mx-auto">
<DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed"> <DialogDescription className="mb-4 text-xs font-medium text-muted-foreground/90 leading-relaxed">
{'您将永久删除当前页面的以下选定存储项。'}
</DialogDescription> </DialogDescription>
{/* 待清理项目徽章群 */} {/* 待清理项目徽章群 */}
@@ -67,7 +69,7 @@ export function StorageCleanerConfirm({
<div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]"> <div className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 rounded-lg bg-destructive/5 border border-dashed border-destructive/20 w-full max-w-[240px]">
<AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" /> <AlertTriangle className="h-3.5 w-3.5 text-destructive shrink-0" />
<span className="text-[11px] font-bold text-destructive leading-none tracking-tight"> <span className="text-[11px] font-bold text-destructive leading-none tracking-tight">
{'此操作不可撤销'}
</span> </span>
</div> </div>
</div> </div>
@@ -79,7 +81,7 @@ export function StorageCleanerConfirm({
onClick={onConfirm} onClick={onConfirm}
className="w-full text-xs font-bold shadow-sm h-9" className="w-full text-xs font-bold shadow-sm h-9"
> >
{'确认清理'}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -87,7 +89,7 @@ export function StorageCleanerConfirm({
onClick={onClose} onClick={onClose}
className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground" className="w-full text-xs font-semibold shadow-sm h-9 text-muted-foreground hover:text-foreground"
> >
{'取消'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -51,7 +51,7 @@ export default function StorageOptionsGrid({
className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors" className="border-t border-border flex justify-between items-center pl-3.5 pr-7 py-2.5 bg-muted/20 hover:bg-muted/40 cursor-pointer select-none transition-colors"
> >
<Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase"> <Label className="text-xs font-bold text-muted-foreground/90 cursor-pointer tracking-wide uppercase">
{'全选所有项'}
</Label> </Label>
<Checkbox <Checkbox
+2 -2
View File
@@ -34,7 +34,7 @@ export default function Index() {
<div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full"> <div className="flex flex-col items-center justify-center py-12 min-h-[280px] w-full">
<Loader2 className="h-6 w-6 text-muted-foreground/80" /> <Loader2 className="h-6 w-6 text-muted-foreground/80" />
<span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide"> <span className="text-xs text-muted-foreground mt-2 font-medium tracking-wide">
... {'正在读取数据...'}
</span> </span>
</div> </div>
); );
@@ -73,7 +73,7 @@ export default function Index() {
{loading ? ( {loading ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
... {'正在清理...'}
</> </>
) : ( ) : (
'立即清理' '立即清理'
+13 -7
View File
@@ -117,6 +117,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}; };
}, []); }, []);
// 核心数据拉取链条
const loadInfo = useCallback(async () => { const loadInfo = useCallback(async () => {
const currentRequestId = ++requestIdRef.current; const currentRequestId = ++requestIdRef.current;
setIsRefreshingSizes(true); setIsRefreshingSizes(true);
@@ -188,6 +189,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
}, 300); }, 300);
}, []); }, []);
// 监听浏览器标签行为
useEffect(() => { useEffect(() => {
loadInfoRef.current().catch(console.error); loadInfoRef.current().catch(console.error);
@@ -214,12 +216,15 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (storageTimerRef.current) clearTimeout(storageTimerRef.current); if (storageTimerRef.current) clearTimeout(storageTimerRef.current);
storageTimerRef.current = setTimeout(async () => { storageTimerRef.current = setTimeout(async () => {
await storageUtil try {
.set('storageCleaner/preferences', { await storageUtil.set('storageCleaner/preferences', {
reloadAfterClean, reloadAfterClean,
selectedTypes: options, selectedTypes: options,
}) });
.catch(console.error); } catch (err) {
console.error('Failed to save storage cleaner preferences:', err);
toast.warning('偏好保存失败,本次设置可能不会保留');
}
}, 500); }, 500);
}, [options, reloadAfterClean, isInitializing]); }, [options, reloadAfterClean, isInitializing]);
@@ -246,7 +251,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
if (loadingRef.current) return; if (loadingRef.current) return;
const tab = await getCurrentTab(); const tab = await getCurrentTab();
if (!tab || !tab.id || !tab.url) { if (!tab || tab.id === undefined || !tab.url) {
toast.warning('无法获取当前标签页'); toast.warning('无法获取当前标签页');
return; return;
} }
@@ -258,8 +263,8 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
} }
const boundTab = boundTabRef.current; const boundTab = boundTabRef.current;
if (!boundTab || boundTab.id !== tab.id || boundTab.url !== tab.url) { if (!boundTab || boundTab.id !== tab.id) {
toast.warning('当前页面已变更,请等待数据刷新后再清理'); toast.warning('当前标签页已切换,请等待数据刷新后再清理');
setShowConfirm(false); setShowConfirm(false);
return; return;
} }
@@ -281,6 +286,7 @@ export function useStorageCleaner(): UseStorageCleanerReturn {
toast.error(`清理失败: ${String(err)}`); toast.error(`清理失败: ${String(err)}`);
} finally { } finally {
setLoading(false); setLoading(false);
setShowConfirm(false);
} }
}, [options, reloadAfterClean, loadInfo]); }, [options, reloadAfterClean, loadInfo]);

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