diff --git a/.github/CODING_STANDARDS.md b/.github/CODING_STANDARDS.md index b19d0a5..691b5c5 100644 --- a/.github/CODING_STANDARDS.md +++ b/.github/CODING_STANDARDS.md @@ -669,7 +669,7 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook ### 9.1 功能元数据 -功能名称与描述在 `config/features.tsx` 的 `FEATURES` 数组中定义: +功能名称与描述在 `config/featureMeta.ts` 的 `FEATURES` 数组中定义: ```typescript { @@ -677,10 +677,11 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook label: '时间戳转换', description: '日期与时间戳互转', defaultVisible: true, - components: { popup: TimestampPage, sidepanel: TimestampPage, tab: TimestampPage }, } ``` +页面组件通过 `config/pageLoaders/` 按需加载,不在 `FEATURES` 中引用。 + Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description`。 ### 9.2 页面与组件文案 @@ -763,11 +764,11 @@ const [themeMode, setThemeMode, isInitialized] = useStorageState( Chrome Storage 读取是异步的。项目通过 `localStorage` 快照(键名 `snapshot/{storageKey}`)提供同步初始值,消除首屏闪烁。 -| 模块 | 快照工具 | 防覆盖机制 | -| ---- | -------- | ---------- | -| `RouterProvider` | `syncSnapshot.ts` | `canPersistRef`(加载成功后才写入)、`hasUserNavigatedRef`(用户导航后不被 storage 覆盖) | -| `useStorageState` | `syncSnapshot.ts` | `loadSucceededRef` 或 `userModifiedRef` 为 true 时才写入 | -| `ThemeModeProvider` | `themeSnapshot.ts` | `hasUserSetMode`(用户切换主题后不被 storage 覆盖) | +| 模块 | 快照工具 | 防覆盖机制 | +| ------------------- | ------------------ | ----------------------------------------------------------------------------------------- | +| `RouterProvider` | `syncSnapshot.ts` | `canPersistRef`(加载成功后才写入)、`hasUserNavigatedRef`(用户导航后不被 storage 覆盖) | +| `useStorageState` | `syncSnapshot.ts` | `loadSucceededRef` 或 `userModifiedRef` 为 true 时才写入 | +| `ThemeModeProvider` | `themeSnapshot.ts` | `hasUserSetMode`(用户切换主题后不被 storage 覆盖) | 新增持久化状态时,应遵循相同模式:同步快照作初始 state → 异步加载 storage → 加载成功或用户修改后才允许写入。 diff --git a/components.json b/components.json index e2c49ef..fbd95c2 100644 --- a/components.json +++ b/components.json @@ -5,7 +5,7 @@ "tsx": true, "tailwind": { "config": "tailwind.config.js", - "css": "src/index.css", + "css": "src/styles/shell.css", "baseColor": "slate", "cssVariables": true, "prefix": "" diff --git a/docs/test-data-generator/README.md b/docs/test-data-generator/README.md index 540cdd0..c356c42 100644 --- a/docs/test-data-generator/README.md +++ b/docs/test-data-generator/README.md @@ -29,3 +29,20 @@ - 导出工具:`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`。 diff --git a/docs/test-data-generator/rule-management.md b/docs/test-data-generator/rule-management.md index 91a4dc1..3600372 100644 --- a/docs/test-data-generator/rule-management.md +++ b/docs/test-data-generator/rule-management.md @@ -24,25 +24,20 @@ ## 数据结构 +> **与源码对齐**:类型定义见 `src/types/testDataGenerator.ts`。规则**不**持久化生成数量与导出格式(由页面 `GenerateOptions` 状态管理)。 + ### 规则模板 ```typescript interface DataRule { - id: string; // 唯一标识 - name: string; // 规则名称 - description?: string; // 规则描述 - fields: FieldConfig[]; // 字段配置 - options: { - total: number; // 生成数量 - format: 'json' | 'csv'; - defaultEmptyRate: number; // 默认空值率(0-100) - }; - metadata: { - createdAt: number; // 创建时间 - updatedAt: number; // 更新时间 - lastUsedAt?: number; // 最后使用时间 - useCount: number; // 使用次数 - }; + id: string; + name: string; + description?: string; + fields: FieldConfig[]; + createdAt: number; + updatedAt: number; + lastUsedAt?: number; + useCount: number; } ``` @@ -50,13 +45,14 @@ interface DataRule { ```typescript interface FieldConfig { - id: string; // 字段唯一标识 - name: string; // 字段名 - generator: string; // 生成器名称 - params: Record; // 生成器参数 - unique: boolean; // 唯一性约束 - required: boolean; // 是否必填 - emptyRate?: number; // 选填字段的空值概率(0-100) + id: string; + name: string; + description?: string; + generatorId: string; // 生成器 ID,对应 lib/generators 中的 id + params: Record; + required: boolean; + nullRate: number; // 空值率 0-100,仅 required=false 时生效 + unique: boolean; } ``` @@ -110,11 +106,9 @@ interface FieldConfig { 1. 验证规则名称不为空 2. 检查规则数量是否达到上限(20 条) -3. 如果达到上限,显示提示"已达到最大规则数量" -4. 生成唯一 ID -5. 设置创建时间和更新时间 -6. 初始化使用次数为 0 -7. 保存到 localStorage +3. 若达上限,`save()` 返回 `null`,UI 应阻止或提示 +4. 生成唯一 ID,写入 `createdAt`/`updatedAt`,`useCount` 初始为 0 +5. 调用 `ruleStorage.save()`;仅当返回值非 `null` 时视为成功(`localStorage` 写入失败同样返回 `null`) --- @@ -167,17 +161,13 @@ interface FieldConfig { - 支持按规则描述搜索 - 搜索为模糊匹配,不区分大小写 -**实现方式**: +**实现方式**(`src/utils/ruleStorage.ts`): ```typescript -search(keyword: string): DataRule[] { - const rules = this.getAll(); - const lowerKeyword = keyword.toLowerCase(); +import * as ruleStorage from '@/utils/ruleStorage'; - return rules.filter(r => - r.name.toLowerCase().includes(lowerKeyword) || - r.description?.toLowerCase().includes(lowerKeyword) - ); +function searchRules(query: string): DataRule[] { + return ruleStorage.search(query); } ``` @@ -224,9 +214,7 @@ search(keyword: string): DataRule[] { 1. 加载原规则配置到编辑器 2. 用户修改配置 -3. 点击保存时更新规则 -4. 更新 metadata.updatedAt -5. 保存到 localStorage +3. 点击保存时调用 `ruleStorage.update()`;返回非 `null` 才更新 `updatedAt` 并提示成功 --- @@ -269,18 +257,12 @@ search(keyword: string): DataRule[] { **实现方式**: ```typescript -loadRule(ruleId: string): void { - const rule = this.storage.getById(ruleId); +function loadRule(ruleId: string): void { + const rule = ruleStorage.getById(ruleId); + if (!rule) return; - // 应用规则配置 - this.setFields(rule.fields); - this.setOptions(rule.options); - - // 记录使用 - this.storage.recordUse(ruleId); - - // 更新预览 - this.updatePreview(); + setFields(rule.fields); + ruleStorage.recordUse(ruleId); } ``` @@ -292,7 +274,7 @@ loadRule(ruleId: string): void { 1. 点击"复制"按钮 2. 创建规则的副本 -3. 名称添加"(副本)"后缀 +3. 名称添加「(副本)」后缀(默认 `duplicate(id, '(副本)')`) 4. 生成新的 ID 5. 保存为新规则 @@ -327,23 +309,20 @@ loadRule(ruleId: string): void { └─────────────────────────────────────────────────────────────────────┘ ``` -**导出格式**: +**导出格式**(`exportRules()` 返回 `DataRule[]` 的 JSON 字符串,无外层包装): ```json -{ - "version": "1.0", - "exportedAt": "2024-01-20T10:15:45.000Z", - "rules": [ - { - "id": "rule_123456", - "name": "电商用户数据 - 测试用", - "description": "用于测试用户注册功能", - "fields": [...], - "options": {...}, - "metadata": {...} - } - ] -} +[ + { + "id": "rule_123456", + "name": "电商用户数据 - 测试用", + "description": "用于测试用户注册功能", + "fields": [], + "createdAt": 1704067200000, + "updatedAt": 1704067200000, + "useCount": 0 + } +] ``` --- @@ -401,63 +380,25 @@ loadRule(ruleId: string): void { ### 本地存储 -使用 localStorage 存储规则数据: +使用 `localStorage`,键名 `testDataGenerator_rules`。API 为**命名导出函数**(见 `src/utils/ruleStorage.ts`): + +| 函数 | 说明 | +| ------------------------------------------------------ | ---------------------------------- | +| `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 -class RuleStorage { - private readonly STORAGE_KEY = 'testDataGenerator_rules'; +import * as ruleStorage from '@/utils/ruleStorage'; - // 获取所有规则 - getAll(): DataRule[] { - 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): 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)); - } - } +const saved = ruleStorage.save({ name: '示例', fields }); +if (!saved) { + // 达上限或 localStorage 不可用 } ``` diff --git a/docs/test-data-generator/technical-implementation.md b/docs/test-data-generator/technical-implementation.md index 2531468..f438a53 100644 --- a/docs/test-data-generator/technical-implementation.md +++ b/docs/test-data-generator/technical-implementation.md @@ -1,5 +1,7 @@ # 技术实现 +> **文档同步说明**:本文档含早期设计稿代码示例,部分类型/API 已与实现偏离。开发时请以 `src/types/testDataGenerator.ts`、`src/workers/generator.worker.ts`、`src/pages/TestDataGenerator/hooks/useGenerator.ts`、`src/utils/ruleStorage.ts` 为准。近期变更摘要见 [README § 开发者注意事项](./README.md#开发者注意事项与源码同步)。 + ## 技术栈 | 技术 | 用途 | 版本 | @@ -1498,6 +1500,67 @@ export class DataExporter { ## 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 使用(历史设计稿,仅供参考) + +
+展开查看旧版设计示例(与当前实现不一致) + ### 创建 Worker ```typescript @@ -1593,7 +1656,7 @@ export function useGenerator() { } ``` -### 错误处理 +### 错误处理(设计稿,`useErrorHandler.ts` 未实现) ```typescript // src/pages/TestDataGenerator/hooks/useErrorHandler.ts @@ -1636,8 +1699,12 @@ export function useErrorHandler(options: ErrorHandlerOptions = {}) { --- +
+ ## 错误提示机制 +> 当前实现:`TestDataGenerator/index.tsx` 直接使用 `useGenerator` 的 `error`/`result` 与 `ResultPanel` 展示警告;下方示例引用未实现的 `useErrorHandler`,仅供对照。 + ### 错误类型分类 | 错误类型 | 严重程度 | 触发场景 | 提示方式 | diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 971e6c2..f70c40d 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -57,11 +57,7 @@ export const CopyButton: React.FC = ({ className={cn(className, copied && 'text-emerald-500')} {...props} > - {copied ? ( - - ) : ( - - )} + {copied ? : } ); }; diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx index 637dc81..277bede 100644 --- a/src/components/ErrorFallback.tsx +++ b/src/components/ErrorFallback.tsx @@ -30,9 +30,7 @@ export function ErrorFallback({
@@ -57,7 +55,11 @@ export function ErrorFallback({

{title}

)} -

+

{description}

@@ -83,7 +85,7 @@ export function ErrorFallback({ variant="destructive" size={isApp ? 'default' : 'sm'} onClick={onAction} - className={cn(isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm')} + className={isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm'} > {actionLabel} diff --git a/src/components/PageSkeleton.tsx b/src/components/PageSkeleton.tsx index 2814850..cf7ed82 100644 --- a/src/components/PageSkeleton.tsx +++ b/src/components/PageSkeleton.tsx @@ -46,7 +46,7 @@ function ToolPageSkeleton() {
-
+
{/* 结果区域 */} diff --git a/src/components/README.md b/src/components/README.md index c8759a0..3dd670c 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -4,20 +4,20 @@ ## 组件列表 -| 组件 | 用途 | -| ----------------------- | ------------------------------------------------------------------------------ | -| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 | -| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | -| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 | -| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | -| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 | -| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 | -| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 | -| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 | -| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 | -| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 | -| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 | -| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback | +| 组件 | 用途 | +| ----------------------- | ------------------------------------------------------------------------------------------------------ | +| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 | +| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | +| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 | +| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | +| `CopyButton.tsx` | 一键复制按钮:复制成功后 1.5s 内切换为 Check 图标并应用 `text-emerald-500`;空内容/失败时 `toast` 提示 | +| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 | +| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 | +| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 | +| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 | +| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 | +| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 | +| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback | ## 使用约定 diff --git a/src/components/RouterContainer.tsx b/src/components/RouterContainer.tsx index 1dbb08a..ef29af1 100644 --- a/src/components/RouterContainer.tsx +++ b/src/components/RouterContainer.tsx @@ -1,54 +1,73 @@ -import { FEATURES, getEntryPointType } from '@/config/features'; +import { getFeatureByKey } from '@/config/features'; +import { loadPage } from '@/config/pageLoaders/index'; import { useRouter } from '@/providers/RouterProvider'; -import { Suspense } from 'react'; +import type { PageType } from '@/types/storage'; +import { type ComponentType, useEffect, useState } from 'react'; import PageErrorBoundary from '@/components/PageErrorBoundary'; import PageSkeleton from '@/components/PageSkeleton'; import { cn } from '@/lib/utils'; import { AlertTriangle } from 'lucide-react'; -const entryPointType = getEntryPointType(); +function LoadedPage({ pageKey }: { pageKey: PageType }) { + const [Page, setPage] = useState(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 ; + } + + return ; +} export default function RouterContainer() { - const { currentPage, isLoaded } = useRouter(); + const { currentPage } = useRouter(); const animationClass = currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter'; - if (!isLoaded) { - return ; - } - - const currentFeature = FEATURES.find((f) => f.key === currentPage); - const MatchedComponent = currentFeature?.components?.[entryPointType]; + const currentFeature = getFeatureByKey(currentPage); return (
- } - > - - {MatchedComponent ? ( - - ) : ( -
-
- -
-

页面未找到

-

- {`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`} -

+ + {currentFeature ? ( + + ) : ( +
+
+
- )} - - +

页面未找到

+

+ 该功能不存在或已被移除。 +

+
+ )} +
); } diff --git a/src/components/SwitchButtonGroup.tsx b/src/components/SwitchButtonGroup.tsx index b0e425d..5035b14 100644 --- a/src/components/SwitchButtonGroup.tsx +++ b/src/components/SwitchButtonGroup.tsx @@ -24,8 +24,7 @@ const SIZE_CLASSES = { large: 'text-base h-11 px-4 py-2 rounded-lg', } as const; -const SELECTED_CLASSES = - 'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150'; +const SELECTED_CLASSES = 'bg-background text-foreground shadow-sm font-semibold fade-in-zoom-95'; const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80'; export default function SwitchButtonGroup({ diff --git a/src/components/TextInputArea.tsx b/src/components/TextInputArea.tsx index f1fcaff..3af1d16 100644 --- a/src/components/TextInputArea.tsx +++ b/src/components/TextInputArea.tsx @@ -267,7 +267,13 @@ const TextInputArea = forwardRef((props
{allowCopy && value && ( - + )} {showClear && value && !disabled && !readOnly && ( @@ -287,7 +293,7 @@ const TextInputArea = forwardRef((props
{displayError && ( -

+

{displayError}

)} diff --git a/src/components/__tests__/RouterContainer.test.tsx b/src/components/__tests__/RouterContainer.test.tsx index a913afd..1691e8c 100644 --- a/src/components/__tests__/RouterContainer.test.tsx +++ b/src/components/__tests__/RouterContainer.test.tsx @@ -9,12 +9,13 @@ const mockRouterValue = { currentPage: 'dashboard' as PageType, visiblePages: ['dashboard', 'timestamp'] as PageType[], pageOrder: ['timestamp'] as PageType[], - isLoaded: true, navigateTo: vi.fn(), syncNavigation: vi.fn(), goHome: vi.fn(), setVisiblePages: vi.fn(), setPageOrder: vi.fn(), + recentlyUsedTools: [] as PageType[], + isLoaded: true, }; vi.mock('@/providers/RouterProvider', () => ({ @@ -32,16 +33,7 @@ describe('RouterContainer 组件', () => { }; describe('渲染测试', () => { - it('isLoaded 为 false 时应渲染骨架屏', () => { - mockRouterValue.isLoaded = false; - const { container } = renderWithProvider(); - // 骨架屏使用 animate-pulse 类 - const skeletons = container.querySelectorAll('.animate-pulse'); - expect(skeletons.length).toBeGreaterThan(0); - }); - - it('isLoaded 为 true 时应渲染页面内容', () => { - mockRouterValue.isLoaded = true; + it('mount 后应直接渲染页面结构(不等待 storage 加载)', () => { mockRouterValue.currentPage = 'dashboard'; const { container } = renderWithProvider(); expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument(); @@ -77,8 +69,7 @@ describe('RouterContainer 组件', () => { }); describe('页面级错误隔离', () => { - it('PageErrorBoundary 应包裹在 Suspense 内层', () => { - mockRouterValue.isLoaded = true; + it('PageErrorBoundary 应包裹页面内容', () => { mockRouterValue.currentPage = 'dashboard'; const { container } = renderWithProvider(); diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 0390bf6..6cec5ff 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -21,6 +21,7 @@ const buttonVariants = cva( sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', icon: 'h-10 w-10', + iconSm: 'h-7 w-7', }, }, defaultVariants: { diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index adcef7b..c1bac23 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -16,7 +16,7 @@ const Checkbox = React.forwardRef< )} {...props} > - + diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 68fa678..7a4d0e4 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef< { expect(pageLoadTracker.loaded).toEqual([]); }); - it('渲染 lazy 组件时才应加载对应页面模块', async () => { - const React = await import('react'); - const { render, waitFor } = await import('@testing-library/react'); - const { FEATURES } = await import('@/config/features'); + it('loadPage 应仅加载对应页面模块', async () => { + const { loadPage } = await import('@/config/pageLoaders/index'); - const DashboardPage = FEATURES.find((f) => f.key === 'dashboard')!.components.popup; + await loadPage('dashboard'); - render( - React.createElement(React.Suspense, { fallback: null }, React.createElement(DashboardPage)), - ); + expect(pageLoadTracker.loaded).toEqual(['Dashboard']); + }); - await waitFor(() => { - expect(pageLoadTracker.loaded).toEqual(['Dashboard']); - }); + it('loadPage 切换页面时不应加载无关页面模块', async () => { + const { loadPage } = await import('@/config/pageLoaders/index'); + + await loadPage('timestamp'); + + expect(pageLoadTracker.loaded).toEqual(['Timestamp']); + expect(pageLoadTracker.loaded).not.toContain('QrCode'); + expect(pageLoadTracker.loaded).not.toContain('TestDataGenerator'); }); }); diff --git a/src/config/__tests__/features.test.ts b/src/config/__tests__/features.test.ts index 07cdecb..d6b06d0 100644 --- a/src/config/__tests__/features.test.ts +++ b/src/config/__tests__/features.test.ts @@ -19,15 +19,11 @@ describe('features', () => { expect(feature).toHaveProperty('label'); expect(feature).toHaveProperty('description'); expect(feature).toHaveProperty('defaultVisible'); - expect(feature).toHaveProperty('components'); + expect(feature).not.toHaveProperty('component'); expect(typeof feature.key).toBe('string'); expect(typeof feature.label).toBe('string'); expect(typeof feature.description).toBe('string'); 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') { expect(feature).toHaveProperty('icon'); diff --git a/src/config/features.tsx b/src/config/featureMeta.ts similarity index 61% rename from src/config/features.tsx rename to src/config/featureMeta.ts index d98d397..c2a274e 100644 --- a/src/config/features.tsx +++ b/src/config/featureMeta.ts @@ -1,4 +1,4 @@ -import { type ComponentType, lazy } from 'react'; +import type { ComponentType } from 'react'; import type { LucideProps } from 'lucide-react'; import type { PageType } from '@/types/storage'; import { @@ -15,18 +15,6 @@ import { 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 { key: PageType; label: string; @@ -34,11 +22,6 @@ export interface FeatureConfig { themeColorKey?: PaletteColorKey; icon?: ComponentType; defaultVisible: boolean; - components: { - popup: ComponentType; - sidepanel: ComponentType; - tab: ComponentType; - }; } export const FEATURES: FeatureConfig[] = [ @@ -47,11 +30,6 @@ export const FEATURES: FeatureConfig[] = [ label: '仪表盘', description: '', defaultVisible: true, - components: { - popup: DashboardPage, - sidepanel: DashboardPage, - tab: DashboardPage, - }, }, { key: 'timestamp', @@ -60,11 +38,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'primary', icon: Clock, defaultVisible: true, - components: { - popup: TimestampPage, - sidepanel: TimestampPage, - tab: TimestampPage, - }, }, { key: 'storageCleaner', @@ -73,11 +46,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'warning', icon: Database, defaultVisible: true, - components: { - popup: StorageCleanerPage, - sidepanel: StorageCleanerPage, - tab: StorageCleanerPage, - }, }, { key: 'qrCode', @@ -86,11 +54,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'success', icon: QrCode, defaultVisible: true, - components: { - popup: QrCodePage, - sidepanel: QrCodePage, - tab: QrCodePage, - }, }, { key: 'textStatistics', @@ -99,11 +62,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'secondary', icon: FileText, defaultVisible: true, - components: { - popup: TextStatisticsPage, - sidepanel: TextStatisticsPage, - tab: TextStatisticsPage, - }, }, { key: 'jwt', @@ -112,11 +70,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'info', icon: Key, defaultVisible: true, - components: { - popup: JwtPage, - sidepanel: JwtPage, - tab: JwtPage, - }, }, { key: 'jsonTools', @@ -125,11 +78,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'primary', icon: GitCompareArrows, defaultVisible: true, - components: { - popup: JsonToolsPage, - sidepanel: JsonToolsPage, - tab: JsonToolsPage, - }, }, { key: 'base64Converter', @@ -138,11 +86,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'info', icon: ArrowLeftRight, defaultVisible: true, - components: { - popup: Base64ConverterPage, - sidepanel: Base64ConverterPage, - tab: Base64ConverterPage, - }, }, { key: 'rightClickRestorer', @@ -151,11 +94,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'success', icon: MousePointerClick, defaultVisible: true, - components: { - popup: RightClickRestorerPage, - sidepanel: RightClickRestorerPage, - tab: RightClickRestorerPage, - }, }, { key: 'testDataGenerator', @@ -164,11 +102,6 @@ export const FEATURES: FeatureConfig[] = [ themeColorKey: 'warning', icon: FileSpreadsheet, defaultVisible: true, - components: { - popup: TestDataGeneratorPage, - sidepanel: TestDataGeneratorPage, - tab: TestDataGeneratorPage, - }, }, ]; diff --git a/src/config/features.ts b/src/config/features.ts new file mode 100644 index 0000000..63648e3 --- /dev/null +++ b/src/config/features.ts @@ -0,0 +1,9 @@ +export type { FeatureConfig, PaletteColorKey } from '@/config/featureMeta'; +export { + FEATURES, + getAllFeatureKeys, + getDefaultPageOrder, + getDefaultVisibleFeatureKeys, + getEntryPointType, + getFeatureByKey, +} from '@/config/featureMeta'; diff --git a/src/config/pageLoaders/base64Converter.ts b/src/config/pageLoaders/base64Converter.ts new file mode 100644 index 0000000..4a8a28d --- /dev/null +++ b/src/config/pageLoaders/base64Converter.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadBase64Converter(): Promise<{ default: ComponentType }> { + return import('@/pages/Base64Converter'); +} diff --git a/src/config/pageLoaders/dashboard.ts b/src/config/pageLoaders/dashboard.ts new file mode 100644 index 0000000..e4b3377 --- /dev/null +++ b/src/config/pageLoaders/dashboard.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadDashboard(): Promise<{ default: ComponentType }> { + return import('@/pages/Dashboard'); +} diff --git a/src/config/pageLoaders/index.ts b/src/config/pageLoaders/index.ts new file mode 100644 index 0000000..d1ff4ad --- /dev/null +++ b/src/config/pageLoaders/index.ts @@ -0,0 +1,45 @@ +import type { ComponentType } from 'react'; +import type { PageType } from '@/types/storage'; + +type PageModule = { default: ComponentType }; + +let pagesStylesLoaded = false; + +function ensurePagesStyles(): Promise { + if (pagesStylesLoaded) { + return Promise.resolve(); + } + pagesStylesLoaded = true; + return import('@/styles/pages.css'); +} + +export function loadPage(key: PageType): Promise { + 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)}`)); + } + } + }); +} diff --git a/src/config/pageLoaders/jsonTools.ts b/src/config/pageLoaders/jsonTools.ts new file mode 100644 index 0000000..ae62f3d --- /dev/null +++ b/src/config/pageLoaders/jsonTools.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadJsonTools(): Promise<{ default: ComponentType }> { + return import('@/pages/JsonTools'); +} diff --git a/src/config/pageLoaders/jwt.ts b/src/config/pageLoaders/jwt.ts new file mode 100644 index 0000000..86e4954 --- /dev/null +++ b/src/config/pageLoaders/jwt.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadJwt(): Promise<{ default: ComponentType }> { + return import('@/pages/Jwt'); +} diff --git a/src/config/pageLoaders/qrCode.ts b/src/config/pageLoaders/qrCode.ts new file mode 100644 index 0000000..879596c --- /dev/null +++ b/src/config/pageLoaders/qrCode.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadQrCode(): Promise<{ default: ComponentType }> { + return import('@/pages/QrCode'); +} diff --git a/src/config/pageLoaders/rightClickRestorer.ts b/src/config/pageLoaders/rightClickRestorer.ts new file mode 100644 index 0000000..aa70570 --- /dev/null +++ b/src/config/pageLoaders/rightClickRestorer.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadRightClickRestorer(): Promise<{ default: ComponentType }> { + return import('@/pages/RightClickRestorer'); +} diff --git a/src/config/pageLoaders/storageCleaner.ts b/src/config/pageLoaders/storageCleaner.ts new file mode 100644 index 0000000..e9212b8 --- /dev/null +++ b/src/config/pageLoaders/storageCleaner.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadStorageCleaner(): Promise<{ default: ComponentType }> { + return import('@/pages/StorageCleaner'); +} diff --git a/src/config/pageLoaders/testDataGenerator.ts b/src/config/pageLoaders/testDataGenerator.ts new file mode 100644 index 0000000..adbb3fa --- /dev/null +++ b/src/config/pageLoaders/testDataGenerator.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadTestDataGenerator(): Promise<{ default: ComponentType }> { + return import('@/pages/TestDataGenerator'); +} diff --git a/src/config/pageLoaders/textStatistics.ts b/src/config/pageLoaders/textStatistics.ts new file mode 100644 index 0000000..558460e --- /dev/null +++ b/src/config/pageLoaders/textStatistics.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadTextStatistics(): Promise<{ default: ComponentType }> { + return import('@/pages/TextStatistics'); +} diff --git a/src/config/pageLoaders/timestamp.ts b/src/config/pageLoaders/timestamp.ts new file mode 100644 index 0000000..498ef61 --- /dev/null +++ b/src/config/pageLoaders/timestamp.ts @@ -0,0 +1,5 @@ +import type { ComponentType } from 'react'; + +export default function loadTimestamp(): Promise<{ default: ComponentType }> { + return import('@/pages/Timestamp'); +} diff --git a/src/entrypoints/popup/App.tsx b/src/entrypoints/popup/App.tsx index 9bda4d4..e7a33ba 100644 --- a/src/entrypoints/popup/App.tsx +++ b/src/entrypoints/popup/App.tsx @@ -25,7 +25,7 @@ export default function App() { visiblePagesKey={routerConfig.visiblePagesKey} pageOrderKey={routerConfig.pageOrderKey} > -
+
diff --git a/src/entrypoints/popup/main.tsx b/src/entrypoints/popup/main.tsx index 6f602b5..7f2f97c 100644 --- a/src/entrypoints/popup/main.tsx +++ b/src/entrypoints/popup/main.tsx @@ -1,6 +1,6 @@ import ReactDOM from 'react-dom/client'; import AppRoot from '@/providers/AppRoot'; -import '@/index.css'; +import '@/styles/shell.css'; import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/entrypoints/sidepanel/App.tsx b/src/entrypoints/sidepanel/App.tsx index 0475e7a..3cd8491 100644 --- a/src/entrypoints/sidepanel/App.tsx +++ b/src/entrypoints/sidepanel/App.tsx @@ -15,7 +15,7 @@ export default function App() { return ( -
+
diff --git a/src/entrypoints/sidepanel/main.tsx b/src/entrypoints/sidepanel/main.tsx index 6f602b5..7f2f97c 100644 --- a/src/entrypoints/sidepanel/main.tsx +++ b/src/entrypoints/sidepanel/main.tsx @@ -1,6 +1,6 @@ import ReactDOM from 'react-dom/client'; import AppRoot from '@/providers/AppRoot'; -import '@/index.css'; +import '@/styles/shell.css'; import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/layout/TopBar/SearchDropdown.tsx b/src/layout/TopBar/SearchDropdown.tsx index 17368c4..b43e84b 100644 --- a/src/layout/TopBar/SearchDropdown.tsx +++ b/src/layout/TopBar/SearchDropdown.tsx @@ -16,16 +16,16 @@ export default function SearchDropdown({ selectedIndex, onSelect, }: SearchDropdownProps) { - const isSearching = searchQuery.trim().length > 0; + const isSearching = !!searchQuery.trim(); const items = isSearching ? searchResults : recentFeatures; return ( -
+
    {!isSearching && items.length > 0 && ( -
    +
  • 最近搜索 -
  • + )} {isSearching && items.length === 0 ? (
  • 未找到相关工具
  • diff --git a/src/layout/TopBar/SearchInput.tsx b/src/layout/TopBar/SearchInput.tsx index 689405c..2a10aab 100644 --- a/src/layout/TopBar/SearchInput.tsx +++ b/src/layout/TopBar/SearchInput.tsx @@ -1,4 +1,4 @@ -import { type RefObject } from 'react'; +import { type KeyboardEvent, type RefObject } from 'react'; import { Search, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -9,7 +9,7 @@ interface SearchInputProps { searchQuery: string; onSearchQueryChange: (value: string) => void; onFocus: () => void; - onKeyDown: (e: React.KeyboardEvent) => void; + onKeyDown: (e: KeyboardEvent) => void; onClear: () => void; } @@ -26,7 +26,6 @@ export default function SearchInput({ onSearchQueryChange(e.target.value)} @@ -35,12 +34,7 @@ export default function SearchInput({ 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" /> - {!searchQuery && ( - - {getSearchShortcutLabel()} - - )} - {searchQuery && ( + {searchQuery ? ( + ) : ( + + {getSearchShortcutLabel()} + )}
); diff --git a/src/layout/TopBar/index.tsx b/src/layout/TopBar/index.tsx index 6513e12..2ad7d57 100644 --- a/src/layout/TopBar/index.tsx +++ b/src/layout/TopBar/index.tsx @@ -33,9 +33,7 @@ export default function TopBar() { id: 'open-in-tab', icon: ExternalLink, title: '在标签页打开', - onClick: () => { - void handleOpenInTab(); - }, + onClick: () => void handleOpenInTab(), }, ]; diff --git a/src/layout/TopBar/useTopBar.ts b/src/layout/TopBar/useTopBar.ts index 1e4b87c..290ac17 100644 --- a/src/layout/TopBar/useTopBar.ts +++ b/src/layout/TopBar/useTopBar.ts @@ -1,4 +1,11 @@ -import { useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type RefObject, +} from 'react'; import { Monitor, Moon, Sun } from 'lucide-react'; import { useRouter } from '@/providers/RouterProvider'; import { useThemeMode } from '@/providers/ThemeModeProvider'; @@ -21,7 +28,7 @@ export interface UseTopBarReturn { handleSearchQueryChange: (value: string) => void; handleSearchFocus: () => void; handleSelectFeature: (feature: FeatureConfig) => void; - handleKeyDown: (e: React.KeyboardEvent) => void; + handleKeyDown: (e: ReactKeyboardEvent) => void; cycleThemeMode: () => void; handleOpenInTab: () => Promise; goHome: () => void; @@ -80,7 +87,7 @@ export function useTopBar(): UseTopBarReturn { }); }, [searchQuery]); - const displayedHistory = useMemo(() => { + const recentFeatures = useMemo(() => { if (searchQuery.trim()) return []; return searchHistory .slice(0, SEARCH_HISTORY_DISPLAY) @@ -111,8 +118,10 @@ export function useTopBar(): UseTopBarReturn { const themeTitle = mode === 'light' ? '切换到深色模式' : mode === 'dark' ? '切换到系统模式' : '切换到浅色模式'; - const handleKeyDown = (e: React.KeyboardEvent) => { - const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; + const handleKeyDown = (e: ReactKeyboardEvent) => { + const isSearching = !!searchQuery.trim(); + const items = isSearching ? searchResults : recentFeatures; + const totalItems = items.length; if (e.key === 'ArrowDown') { e.preventDefault(); @@ -122,13 +131,12 @@ export function useTopBar(): UseTopBarReturn { setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev)); } else if (e.key === 'Enter') { e.preventDefault(); - const items = searchQuery.trim() ? searchResults : displayedHistory; - const feature = - selectedIndex >= 0 && selectedIndex < totalItems - ? items[selectedIndex] - : searchQuery.trim() && searchResults.length > 0 - ? searchResults[0] - : undefined; + let feature: FeatureConfig | undefined; + if (selectedIndex >= 0 && selectedIndex < totalItems) { + feature = items[selectedIndex]; + } else if (isSearching && searchResults.length > 0) { + feature = searchResults[0]; + } if (feature) handleSelectFeature(feature); } else if (e.key === 'Escape') { setShowResults(false); @@ -149,13 +157,12 @@ export function useTopBar(): UseTopBarReturn { const handleSearchFocus = () => setShowResults(true); - const showDropdown = - showResults && (searchQuery.trim().length > 0 || displayedHistory.length > 0); + const showDropdown = showResults && (!!searchQuery.trim() || recentFeatures.length > 0); return { searchQuery, searchResults, - recentFeatures: displayedHistory, + recentFeatures, selectedIndex, showDropdown, isDashboard: currentPage === 'dashboard', diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9ad0df4..b4b4fa2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,5 @@ import { type ClassValue, clsx } from 'clsx'; -import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return clsx(inputs); } diff --git a/src/pages/Base64Converter/__tests__/index.test.tsx b/src/pages/Base64Converter/__tests__/index.test.tsx index da1cb0c..bdc9308 100644 --- a/src/pages/Base64Converter/__tests__/index.test.tsx +++ b/src/pages/Base64Converter/__tests__/index.test.tsx @@ -22,7 +22,9 @@ vi.mock('../components/TextMode', () => ({ })); vi.mock('../components/Base64ConverterSection', () => ({ - default: ({ mode }: { mode: string }) =>
{mode}
, + default: ({ mode }: { mode: 'file' | 'image' }) => ( +
{mode.toUpperCase()} Mode
+ ), })); const waitForStorageInit = () => diff --git a/src/pages/Base64Converter/components/Base64ConverterSection.tsx b/src/pages/Base64Converter/components/Base64ConverterSection.tsx index a3ec4ee..0118872 100644 --- a/src/pages/Base64Converter/components/Base64ConverterSection.tsx +++ b/src/pages/Base64Converter/components/Base64ConverterSection.tsx @@ -50,10 +50,6 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP setDirection(next); }; - const handleDownload = () => { - if (decoded) downloadBlob(decoded.blob, decodedFileName); - }; - return (
@@ -115,7 +111,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP />
)} - + {info.name} @@ -137,11 +133,11 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP {mode === 'image' ? '点击或拖拽图像到此处' : '点击或拖拽文件到此处'} - {`最大文件大小:${maxFileSizeStr}`} + 最大文件大小:{maxFileSizeStr} {mode === 'image' && ( - {'支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式'} + 支持 PNG、JPG、WEBP、GIF、BMP、SVG 等格式 )}
@@ -231,7 +227,7 @@ export default function Base64ConverterSection({ mode }: Base64ConverterSectionP blobSize={decoded.blob.size} fileName={decodedFileName} onFileNameChange={setCustomFileName} - onDownload={handleDownload} + onDownload={() => downloadBlob(decoded.blob, decodedFileName)} > {mode === 'image' && (
diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 69acdde..37cb368 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -1,11 +1,10 @@ -import { cn } from '@/lib/utils'; import { useDashboard } from './useDashboard'; export default function Index() { const { visibleFeatures, recentFeatures, showRecent, navigateTo } = useDashboard(); return ( -
+
{showRecent && (

@@ -19,12 +18,7 @@ export default function Index() { key={key} type="button" onClick={() => navigateTo(key)} - 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', - )} + 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" > {feature.label} @@ -42,9 +36,7 @@ export default function Index() { {visibleFeatures.length === 0 ? (

没有可用的工具

) : ( -
+
{visibleFeatures.map(({ key, feature }) => { const IconComponent = feature.icon; return ( @@ -52,20 +44,9 @@ export default function Index() { key={key} type="button" onClick={() => navigateTo(key)} - 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', - )} + 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" > - + {feature.label} diff --git a/src/pages/QrCode/components/QrCodePreview.tsx b/src/pages/QrCode/components/QrCodePreview.tsx index 68cb8a3..ca95d42 100644 --- a/src/pages/QrCode/components/QrCodePreview.tsx +++ b/src/pages/QrCode/components/QrCodePreview.tsx @@ -44,7 +44,7 @@ const QrCodePreview = ({ QR Code Preview
diff --git a/src/pages/StorageCleaner/__tests__/index.test.tsx b/src/pages/StorageCleaner/__tests__/index.test.tsx index bd348fc..2a19137 100644 --- a/src/pages/StorageCleaner/__tests__/index.test.tsx +++ b/src/pages/StorageCleaner/__tests__/index.test.tsx @@ -126,7 +126,34 @@ describe('StorageCleaner 页面', () => { await waitFor(() => { 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(); + + 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('当前页面已变更,请等待数据刷新后再清理'); }); }); }); diff --git a/src/pages/StorageCleaner/useStorageCleaner.ts b/src/pages/StorageCleaner/useStorageCleaner.ts index 5dbe66e..3bed552 100644 --- a/src/pages/StorageCleaner/useStorageCleaner.ts +++ b/src/pages/StorageCleaner/useStorageCleaner.ts @@ -258,8 +258,8 @@ export function useStorageCleaner(): UseStorageCleanerReturn { } const boundTab = boundTabRef.current; - if (!boundTab || boundTab.id !== tab.id) { - toast.warning('当前标签页已切换,请等待数据刷新后再清理'); + if (!boundTab || boundTab.id !== tab.id || boundTab.url !== tab.url) { + toast.warning('当前页面已变更,请等待数据刷新后再清理'); setShowConfirm(false); return; } diff --git a/src/pages/TestDataGenerator/components/FieldList.tsx b/src/pages/TestDataGenerator/components/FieldList.tsx index 1c4870a..89c538f 100644 --- a/src/pages/TestDataGenerator/components/FieldList.tsx +++ b/src/pages/TestDataGenerator/components/FieldList.tsx @@ -234,29 +234,38 @@ export default function FieldList({ if (!ruleName.trim()) return; const trimmedName = ruleName.trim(); + const existingRule = ruleStorage.getByName(trimmedName); // 检查名称是否重复 - if (!overwrite) { - const existingRule = ruleStorage.getByName(trimmedName); - if (existingRule) { - setShowConfirmOverwrite(true); - return; - } + if (!overwrite && existingRule) { + setShowConfirmOverwrite(true); + return; } - const newRule = ruleStorage.save({ - name: trimmedName, - description: ruleDescription.trim(), - fields: fields, - }); + const savedRule = ruleStorage.save( + overwrite && existingRule + ? { + id: existingRule.id, + name: trimmedName, + description: ruleDescription.trim(), + fields: fields, + } + : { + name: trimmedName, + description: ruleDescription.trim(), + fields: fields, + }, + ); - if (newRule) { + if (savedRule) { setShowSaveDialog(false); setShowConfirmOverwrite(false); setRuleName(''); setRuleDescription(''); - toast.success('规则已保存'); + toast.success(overwrite ? '规则已覆盖' : '规则已保存'); onRuleSaved?.(); + } else { + toast.error('规则保存失败'); } }, [ruleName, ruleDescription, fields, onRuleSaved], @@ -277,7 +286,7 @@ export default function FieldList({ onClick={handleUpdateRule} disabled={fields.length === 0} className="h-8 gap-1.5 px-2.5" - title={`编辑中: ${editingRule.name}`} + title={editingRule ? `编辑中: ${editingRule.name}` : ''} > 更新规则 diff --git a/src/pages/TestDataGenerator/components/GenerateButton.tsx b/src/pages/TestDataGenerator/components/GenerateButton.tsx index 25802fa..cfa5e7f 100644 --- a/src/pages/TestDataGenerator/components/GenerateButton.tsx +++ b/src/pages/TestDataGenerator/components/GenerateButton.tsx @@ -33,7 +33,10 @@ export default function GenerateButton({ {progress && (
- {`已生成 ${progress.generated.toLocaleString()} / ${progress.total.toLocaleString()} 条`} + + 已生成 {progress.generated.toLocaleString()} / {progress.total.toLocaleString()}{' '} + 条 + {progress.progress}%
@@ -44,7 +47,7 @@ export default function GenerateButton({
{progress.estimatedTimeLeft !== undefined && (

- {`预计剩余 ${Math.ceil(progress.estimatedTimeLeft / 1000)} 秒`} + 预计剩余 {Math.ceil(progress.estimatedTimeLeft / 1000)} 秒

)}
diff --git a/src/pages/TestDataGenerator/components/ResultPanel.tsx b/src/pages/TestDataGenerator/components/ResultPanel.tsx index 87e0c2c..cefd975 100644 --- a/src/pages/TestDataGenerator/components/ResultPanel.tsx +++ b/src/pages/TestDataGenerator/components/ResultPanel.tsx @@ -82,7 +82,7 @@ export default function ResultPanel({ result }: ResultPanelProps) { ))} {result.warnings.length > 5 && (
  • - {`... 还有 ${result.warnings.length - 5} 条警告`} + ... 还有 {result.warnings.length - 5} 条警告
  • )} diff --git a/src/pages/TestDataGenerator/components/RuleManager.tsx b/src/pages/TestDataGenerator/components/RuleManager.tsx index b65c89b..22bc2c8 100644 --- a/src/pages/TestDataGenerator/components/RuleManager.tsx +++ b/src/pages/TestDataGenerator/components/RuleManager.tsx @@ -227,7 +227,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana maxLength={20} /> - {`已保存 ${rules.length}/${ruleStorage.MAX_RULES} 条`} + 已保存 {rules.length}/{ruleStorage.MAX_RULES} 条
    @@ -260,7 +260,7 @@ export default function RuleManager({ onLoad, onEdit, onRulesChanged }: RuleMana {formatDate(rule.updatedAt)} - {`使用 ${rule.useCount} 次`} + 使用 {rule.useCount} 次

    diff --git a/src/pages/TestDataGenerator/components/__tests__/FieldList.test.tsx b/src/pages/TestDataGenerator/components/__tests__/FieldList.test.tsx new file mode 100644 index 0000000..8d462af --- /dev/null +++ b/src/pages/TestDataGenerator/components/__tests__/FieldList.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FieldConfig } from '@/types/testDataGenerator'; + +vi.mock('@/utils/ruleStorage', () => ({ + getByName: vi.fn(), + save: vi.fn(), + update: vi.fn(), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +import FieldList from '../FieldList'; +import * as ruleStorage from '@/utils/ruleStorage'; +import { toast } from 'sonner'; + +const mockedRuleStorage = vi.mocked(ruleStorage); +const mockedToast = vi.mocked(toast); + +const mockFields: FieldConfig[] = [ + { + id: 'field-1', + name: 'username', + generatorId: 'string', + params: {}, + required: true, + nullRate: 0, + unique: false, + }, +]; + +const defaultProps = { + fields: mockFields, + onUpdate: vi.fn(), + onRemove: vi.fn(), + onAdd: vi.fn(), + onEdit: vi.fn(), + onReorder: vi.fn(), +}; + +describe('FieldList 规则保存', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedRuleStorage.getByName.mockReturnValue(undefined); + mockedRuleStorage.save.mockReturnValue({ + id: 'rule-1', + name: 'My Rule', + fields: mockFields, + createdAt: Date.now(), + updatedAt: Date.now(), + useCount: 0, + }); + }); + + it('覆盖同名规则时应更新已有规则而非新建', async () => { + const user = userEvent.setup(); + const existingRule = { + id: 'existing-rule-id', + name: 'My Rule', + fields: mockFields, + createdAt: Date.now(), + updatedAt: Date.now(), + useCount: 0, + }; + + mockedRuleStorage.getByName.mockReturnValue(existingRule); + + render(); + + await user.click(screen.getByRole('button', { name: /保存规则/ })); + await user.type(screen.getByPlaceholderText('规则名称'), 'My Rule'); + await user.click(screen.getByRole('button', { name: '确认' })); + + expect(screen.getByText('已存在同名规则,是否覆盖保存?')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '覆盖' })); + + expect(mockedRuleStorage.save).toHaveBeenCalledWith({ + id: 'existing-rule-id', + name: 'My Rule', + description: '', + fields: mockFields, + }); + expect(mockedToast.success).toHaveBeenCalledWith('规则已覆盖'); + }); +}); diff --git a/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts b/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts index 7ce233a..5d2ef73 100644 --- a/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts +++ b/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts @@ -114,4 +114,25 @@ describe('useGenerator', () => { expect(result.current.isGenerating).toBe(false); expect(worker.postedMessages).toEqual(expect.arrayContaining([{ type: 'cancel' }])); }); + + it('正在生成时不应重复发送 start 消息', () => { + const { result } = renderHook(() => useGenerator()); + + act(() => { + result.current.generate([mockField], 10); + result.current.generate([mockField], 20); + }); + + const worker = MockWorker.instances[0]; + const startMessages = worker.postedMessages.filter( + (message): message is { type: 'start'; payload: { count: number } } => + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'start', + ); + + expect(startMessages).toHaveLength(1); + expect(startMessages[0]?.payload.count).toBe(10); + }); }); diff --git a/src/pages/TestDataGenerator/hooks/useGenerator.ts b/src/pages/TestDataGenerator/hooks/useGenerator.ts index e1d3221..2e5e1e7 100644 --- a/src/pages/TestDataGenerator/hooks/useGenerator.ts +++ b/src/pages/TestDataGenerator/hooks/useGenerator.ts @@ -37,6 +37,12 @@ export function useGenerator(): UseGeneratorReturn { const workerRef = useRef(null); const generationIdRef = useRef(0); + const isGeneratingRef = useRef(false); + + const finishGenerating = useCallback(() => { + isGeneratingRef.current = false; + setIsGenerating(false); + }, []); // 清理 Worker useEffect(() => { @@ -73,7 +79,7 @@ export function useGenerator(): UseGeneratorReturn { setProgress(data.payload); break; case 'complete': - setIsGenerating(false); + finishGenerating(); if (data.payload.success) { setResult(data.payload); } else if (data.payload.error && data.payload.error !== '生成已取消') { @@ -82,7 +88,7 @@ export function useGenerator(): UseGeneratorReturn { setProgress(null); break; case 'error': - setIsGenerating(false); + finishGenerating(); setError(data.payload.error); setProgress(null); break; @@ -91,7 +97,7 @@ export function useGenerator(): UseGeneratorReturn { worker.onerror = (err) => { console.error('[useGenerator] Worker 错误:', err); - setIsGenerating(false); + finishGenerating(); setError(err.message || 'Worker 运行错误'); setProgress(null); // Worker 出错后销毁,下次重新创建 @@ -101,16 +107,17 @@ export function useGenerator(): UseGeneratorReturn { workerRef.current = worker; return worker; - }, []); + }, [finishGenerating]); /** * 开始生成 */ const generate = useCallback( (fields: FieldConfig[], count: number, csvMode = false) => { - if (isGenerating) return; + if (isGeneratingRef.current) return; const generationId = ++generationIdRef.current; + isGeneratingRef.current = true; setIsGenerating(true); setProgress(null); @@ -124,21 +131,22 @@ export function useGenerator(): UseGeneratorReturn { }; worker.postMessage(message); }, - [isGenerating, getWorker], + [getWorker], ); /** * 取消生成 */ const cancel = useCallback(() => { - if (workerRef.current && isGenerating) { + if (workerRef.current && isGeneratingRef.current) { ++generationIdRef.current; const message: WorkerRequestMessage = { type: 'cancel' }; workerRef.current.postMessage(message); + isGeneratingRef.current = false; setIsGenerating(false); setProgress(null); } - }, [isGenerating]); + }, []); /** * 清除结果 diff --git a/src/providers/RouterProvider.tsx b/src/providers/RouterProvider.tsx index 8790f09..b07ea7e 100644 --- a/src/providers/RouterProvider.tsx +++ b/src/providers/RouterProvider.tsx @@ -110,13 +110,19 @@ export function RouterProvider({ const loadInitialData = useCallback(async () => { try { - const savedRoute = await storageUtil.get(syncKey, defaultRoute); - const savedVisiblePages = await storageUtil.get( + const stored = await storageUtil.getMany([ + syncKey, visiblePagesKey, - getDefaultVisibleFeatureKeys(), - ); - const savedPageOrder = await storageUtil.get(pageOrderKey, getDefaultPageOrder()); - const savedRecentTools = await storageUtil.get('app/recentlyUsedTools', []); + pageOrderKey, + 'app/recentlyUsedTools', + 'contextMenu/pendingData', + ]); + + const savedRoute = (stored[syncKey] ?? defaultRoute) as PageType; + const savedVisiblePages = (stored[visiblePagesKey] ?? + getDefaultVisibleFeatureKeys()) as PageType[]; + const savedPageOrder = (stored[pageOrderKey] ?? getDefaultPageOrder()) as PageType[]; + const savedRecentTools = (stored['app/recentlyUsedTools'] ?? []) as PageType[]; if (isValidPage(savedRoute) && syncRoute && !hasUserNavigatedRef.current) { setCurrentPage(savedRoute); @@ -131,8 +137,11 @@ export function RouterProvider({ setRecentlyUsedTools(savedRecentTools); } canPersistRef.current = true; + + return stored['contextMenu/pendingData']; } catch (error) { console.error('[Router Init Error] Core data fetch failed:', error); + return undefined; } finally { setIsLoaded(true); } @@ -143,7 +152,7 @@ export function RouterProvider({ // eslint-disable-next-line react-hooks/set-state-in-effect -- Valid async data loading pattern on mount loadInitialData() - .then(() => { + .then((pendingData) => { if (cancelled) return; if (typeof window !== 'undefined') { @@ -165,18 +174,13 @@ export function RouterProvider({ } } - storageUtil - .get('contextMenu/pendingData') - .then((pendingData) => { - if ( - pendingData && - isValidPage(pendingData.featureKey) && - Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS - ) { - setCurrentPage(pendingData.featureKey as PageType); - } - }) - .catch(console.error); + if ( + pendingData && + isValidPage(pendingData.featureKey) && + Date.now() - pendingData.timestamp < CONTEXT_MENU_DATA_EXPIRY_MS + ) { + setCurrentPage(pendingData.featureKey as PageType); + } }) .catch(console.error); diff --git a/src/providers/ThemeModeProvider.tsx b/src/providers/ThemeModeProvider.tsx index 4ee6d32..449b039 100644 --- a/src/providers/ThemeModeProvider.tsx +++ b/src/providers/ThemeModeProvider.tsx @@ -13,6 +13,7 @@ import { storageUtil } from '@/utils/chromeStorage'; import { applyResolvedThemeClass, getThemeSyncSnapshot, + hasThemeSyncSnapshot, isValidThemeMode, persistThemeModeSnapshot, resolveThemeMode, @@ -68,6 +69,10 @@ export function ThemeModeProvider({ children }: ThemeModeProviderProps) { ); useEffect(() => { + if (hasThemeSyncSnapshot()) { + return; + } + let cancelled = false; storageUtil diff --git a/src/providers/__tests__/RouterProvider.test.tsx b/src/providers/__tests__/RouterProvider.test.tsx index f9830bb..f22e93c 100644 --- a/src/providers/__tests__/RouterProvider.test.tsx +++ b/src/providers/__tests__/RouterProvider.test.tsx @@ -7,10 +7,24 @@ import { storageUtil } from '@/utils/chromeStorage'; vi.mock('@/utils/chromeStorage', () => ({ storageUtil: { get: vi.fn(), + getMany: vi.fn(), set: vi.fn(() => Promise.resolve()), }, })); +/** 模拟批量 storage 读取,未指定的键由 Router 侧使用默认值 */ +function mockStorageBatch(map: Record = {}) { + (storageUtil.getMany as ReturnType).mockImplementation((keys: string[]) => { + const result: Record = {}; + for (const key of keys) { + if (key in map) { + result[key] = map[key]; + } + } + return Promise.resolve(result); + }); +} + const TestComponent = () => { const { currentPage, navigateTo, visiblePages, pageOrder } = useRouter(); return ( @@ -46,9 +60,7 @@ describe('RouterProvider', () => { }); it('应该使用默认值初始化路由', async () => { - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -59,13 +71,18 @@ describe('RouterProvider', () => { await waitFor(() => { expect(screen.getByTestId('current-page')).toHaveTextContent('dashboard'); }); + + expect(storageUtil.getMany).toHaveBeenCalledWith([ + 'app/currentRoute', + 'app/visiblePages', + 'app/pageOrder', + 'app/recentlyUsedTools', + 'contextMenu/pendingData', + ]); }); it('应该从指定的 syncKey 加载路由', async () => { - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/sidepanelRoute') return Promise.resolve('timestamp'); - return Promise.resolve(defaultValue); - }); + mockStorageBatch({ 'app/sidepanelRoute': 'timestamp' }); render( @@ -79,9 +96,7 @@ describe('RouterProvider', () => { }); it('导航时应该更新指定的 syncKey', async () => { - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -104,10 +119,7 @@ describe('RouterProvider', () => { }); it('应该支持独立的标签页路由同步', async () => { - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/tabRoute') return Promise.resolve('qrCode'); - return Promise.resolve(defaultValue); - }); + mockStorageBatch({ 'app/tabRoute': 'qrCode' }); render( @@ -121,11 +133,9 @@ describe('RouterProvider', () => { }); it('应该独立支持 visiblePagesKey 和 pageOrderKey,并合并缺失的新功能', async () => { - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/sidepanelVisiblePages') - return Promise.resolve(['timestamp', 'storageCleaner']); - if (key === 'app/sidepanelPageOrder') return Promise.resolve(['storageCleaner', 'timestamp']); - return Promise.resolve(defaultValue); + mockStorageBatch({ + 'app/sidepanelVisiblePages': ['timestamp', 'storageCleaner'], + 'app/sidepanelPageOrder': ['storageCleaner', 'timestamp'], }); render( @@ -167,10 +177,9 @@ describe('RouterProvider', () => { 'jsonTools', ]; - (storageUtil.get as any).mockImplementation((key: string, defaultValue: any) => { - if (key === 'app/popupVisiblePages') return Promise.resolve(oldVisiblePages); - if (key === 'app/popupPageOrder') return Promise.resolve(oldPageOrder); - return Promise.resolve(defaultValue); + mockStorageBatch({ + 'app/popupVisiblePages': oldVisiblePages, + 'app/popupPageOrder': oldPageOrder, }); render( @@ -209,9 +218,7 @@ describe('RouterProvider', () => { localStorage.setItem('snapshot/app/visiblePages', JSON.stringify(oldVisiblePages)); localStorage.setItem('snapshot/app/pageOrder', JSON.stringify(oldPageOrder)); - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: any) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -226,9 +233,7 @@ describe('RouterProvider', () => { }); it('storage.onChanged 同步 visiblePages 时应合并缺失的新功能', async () => { - (storageUtil.get as any).mockImplementation((_key: string, defaultValue: unknown) => - Promise.resolve(defaultValue), - ); + mockStorageBatch(); render( @@ -299,9 +304,9 @@ describe('RouterProvider', () => { resolveGet = resolve; }); - (storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { + (storageUtil.getMany as ReturnType).mockImplementation(async () => { await getBlocked; - return storage.get(key) ?? defaultValue; + return Object.fromEntries(storage.entries()); }); (storageUtil.set as any).mockImplementation(async (key: string, value: unknown) => { storage.set(key, value); @@ -324,7 +329,9 @@ describe('RouterProvider', () => { }); it('初始化失败时仍应解除加载状态以便渲染页面', async () => { - (storageUtil.get as any).mockRejectedValue(new Error('Storage unavailable')); + (storageUtil.getMany as ReturnType).mockRejectedValue( + new Error('Storage unavailable'), + ); render( @@ -344,10 +351,9 @@ describe('RouterProvider', () => { resolveGet = resolve; }); - (storageUtil.get as any).mockImplementation(async (key: string, defaultValue: unknown) => { + (storageUtil.getMany as ReturnType).mockImplementation(async () => { await getBlocked; - if (key === 'app/currentRoute') return 'dashboard'; - return defaultValue; + return { 'app/currentRoute': 'dashboard' }; }); render( @@ -374,7 +380,7 @@ describe('RouterProvider', () => { resolveStorage = resolve; }); - (storageUtil.get as any).mockImplementation(() => storagePromise); + (storageUtil.getMany as ReturnType).mockImplementation(() => storagePromise); const { unmount } = render( @@ -383,7 +389,7 @@ describe('RouterProvider', () => { ); unmount(); - resolveStorage!('dashboard'); + resolveStorage!({}); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/src/providers/__tests__/ThemeModeProvider.test.tsx b/src/providers/__tests__/ThemeModeProvider.test.tsx index 9793655..abc739c 100644 --- a/src/providers/__tests__/ThemeModeProvider.test.tsx +++ b/src/providers/__tests__/ThemeModeProvider.test.tsx @@ -46,6 +46,7 @@ describe('ThemeModeProvider', () => { expect(screen.getByTestId('mode')).toHaveTextContent('dark'); expect(screen.getByTestId('resolved-mode')).toHaveTextContent('dark'); expect(document.documentElement.classList.contains('dark')).toBe(true); + expect(storageUtil.get).not.toHaveBeenCalled(); }); it('异步恢复 storage 后应写回 snapshot', async () => { diff --git a/src/styles/pages.css b/src/styles/pages.css new file mode 100644 index 0000000..fb7a340 --- /dev/null +++ b/src/styles/pages.css @@ -0,0 +1,3 @@ +@config "../../tailwind.pages.config.js"; + +@tailwind utilities; diff --git a/src/index.css b/src/styles/shell.css similarity index 57% rename from src/index.css rename to src/styles/shell.css index a92c1bc..7a1b002 100644 --- a/src/index.css +++ b/src/styles/shell.css @@ -1,3 +1,5 @@ +@config "../../tailwind.shell.config.js"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -90,3 +92,91 @@ ::-webkit-scrollbar { 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; + } +} diff --git a/src/types/README.md b/src/types/README.md index b91d268..756a3f3 100644 --- a/src/types/README.md +++ b/src/types/README.md @@ -39,7 +39,9 @@ - `FieldConfig` — 字段配置(字段名、生成器、参数、必填、空值率、唯一性) - `DataRule` — 可保存/导入/导出的字段规则 - `GeneratorDefinition` / `GeneratorParam` — 内置生成器定义和参数 Schema -- `GenerateResult` / `GenerateProgress` / `WorkerMessage` — Worker 生成结果、进度和消息协议 +- `GenerateResult` / `GenerateProgress` — Worker 生成结果与进度 +- `WorkerRequestMessage` / `WorkerResponseMessage` — Worker 消息协议;每条响应携带 `generationId`,用于忽略过期任务(取消或快速重试时) +- `WorkerMessage` — 已废弃,请使用上述两种消息类型 - `ExportFile` — JSON/CSV 导出文件描述 ## 修改 StorageSchema 的注意事项 diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 0000000..ed6ca81 --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const css: string; + export default css; +} diff --git a/src/utils/README.md b/src/utils/README.md index 0cbdcd7..fefd6e0 100644 --- a/src/utils/README.md +++ b/src/utils/README.md @@ -7,8 +7,8 @@ | 文件 | 用途 | | ------------------------ | --------------------------------------------------------------------------------------------------- | | `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 | -| `syncSnapshot.ts` | 通用 `localStorage` 快照读取(`snapshot/{key}`),用于 Router 与 `useStorageState` 首屏防闪烁 | -| `themeSnapshot.ts` | 主题专用快照读写与 `document.documentElement` class 切换 | +| `syncSnapshot.ts` | 通用 `localStorage` 快照读取(`snapshot/{key}`),用于 Router 与 `useStorageState` 首屏防闪烁 | +| `themeSnapshot.ts` | 主题专用快照读写与 `document.documentElement` class 切换 | | `restrictedUrls.ts` | 受限 URL 检测(`chrome://`、`about:` 等),供 Storage Cleaner 等模块复用 | | `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 | | `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) | @@ -24,7 +24,7 @@ | `textStatistics.ts` | 文本统计:使用 `Intl.Segmenter` 计算字符数/单词数/行数/字节大小 | | `format.ts` | 通用格式化:`formatBytes` 将字节转为可读字符串(B/KB/MB/GB/TB) | | `dayjs.ts` | Day.js 初始化:扩展 UTC、Timezone、RelativeTime 插件,加载中文本地化 | -| `ruleStorage.ts` | 测试数据生成器规则存储:基于 `localStorage` 的 CRUD、搜索、导入/导出和数量限制 | +| `ruleStorage.ts` | 测试数据生成器规则存储:基于 `localStorage` 的 CRUD、搜索、导入/导出和数量限制(见下方说明) | | `dataExporter.ts` | 测试数据导出:JSON/CSV 转换、文件下载和复制到剪贴板 | | `rightClickInjection.ts` | 右键恢复注入脚本:在页面上下文恢复 contextmenu/copy/paste 等事件默认行为 | @@ -36,6 +36,23 @@ | `useContextMenuData.ts` | 右键菜单数据 Hook:从 storage 读取待处理数据,匹配 featureKey 后消费并触发回调 | | `useDebounce.ts` | 防抖 Hook:对值进行延迟更新,避免频繁触发 | +### ruleStorage 写入失败处理 + +`ruleStorage.ts` 使用函数式导出(非 class),存储键为 `testDataGenerator_rules`,最多 `MAX_RULES = 20` 条。 + +写入经内部 `setAll()` 完成;`localStorage.setItem` 抛错(如配额超限)时返回 `false`,并 `console.error`,**不会部分提交**: + +| 方法 | 写入失败返回值 | 常见失败原因 | +| -------------- | ---------------------------- | ------------------------------------------------------ | +| `save()` | `null` | 达上限、规则不存在(更新时)、`setItem` 异常 | +| `update()` | `null` | 规则不存在、`setItem` 异常 | +| `deleteRule()` | `false` | 规则不存在、`setItem` 异常 | +| `duplicate()` | 仍可能返回对象但数据未持久化 | 见源码:`duplicate` 未检查 `setAll` 返回值(已知缺口) | + +调用方应检查返回值后再展示成功 Toast。页面层参考 `FieldList.tsx`:`save`/`update` 返回非空才提示「规则已保存/已更新」。 + +规则仅持久化 `name`、`description`、`fields` 及时间戳/使用统计;生成数量与导出格式由页面状态管理,不写入规则。 + ### useStorageState 初始化防覆盖 `useStorageState` 在挂载时从 storage 异步加载。写入 storage 需满足以下任一条件: diff --git a/src/utils/__tests__/chromeStorage.test.ts b/src/utils/__tests__/chromeStorage.test.ts index 1ada37e..0b8d449 100644 --- a/src/utils/__tests__/chromeStorage.test.ts +++ b/src/utils/__tests__/chromeStorage.test.ts @@ -75,6 +75,27 @@ describe('chromeStorage', () => { }); }); + describe('getMany', () => { + it('应该一次读取多个键', async () => { + (chrome.storage.local.get as any).mockResolvedValue({ + 'app/theme': 'dark', + 'app/popupRoute': 'timestamp', + }); + + const result = await storageUtil.getMany(['app/theme', 'app/popupRoute']); + + expect(result).toEqual({ 'app/theme': 'dark', 'app/popupRoute': 'timestamp' }); + expect(chrome.storage.local.get).toHaveBeenCalledWith(['app/theme', 'app/popupRoute']); + }); + + it('空键数组应直接返回空对象', async () => { + const result = await storageUtil.getMany([]); + + expect(result).toEqual({}); + expect(chrome.storage.local.get).not.toHaveBeenCalled(); + }); + }); + describe('get 类型签名', () => { it('无默认值时应推断为可选返回类型', () => { const _getWithoutDefault = () => storageUtil.get('app/theme'); diff --git a/src/utils/__tests__/ruleStorage.test.ts b/src/utils/__tests__/ruleStorage.test.ts index 27330d7..a7b6434 100644 --- a/src/utils/__tests__/ruleStorage.test.ts +++ b/src/utils/__tests__/ruleStorage.test.ts @@ -48,6 +48,27 @@ describe('ruleStorage', () => { expect(updated).toBeNull(); }); + it('save 带 id 时应更新已有规则而非新建', () => { + const first = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + expect(first).not.toBeNull(); + + const updatedField = { ...mockField, name: 'email' }; + const updated = ruleStorage.save({ + id: first!.id, + name: 'Test Rule', + description: 'Updated', + fields: [updatedField], + }); + + expect(updated).not.toBeNull(); + expect(ruleStorage.getCount()).toBe(1); + expect(ruleStorage.getById(first!.id)?.fields[0].name).toBe('email'); + expect(ruleStorage.getById(first!.id)?.description).toBe('Updated'); + }); + it('deleteRule 在 localStorage 写入失败时应返回 false', () => { const saved = ruleStorage.save({ name: 'Test Rule', diff --git a/src/utils/__tests__/useStorageState.test.ts b/src/utils/__tests__/useStorageState.test.ts index 3f56489..99a6928 100644 --- a/src/utils/__tests__/useStorageState.test.ts +++ b/src/utils/__tests__/useStorageState.test.ts @@ -40,6 +40,7 @@ describe('useStorageState', () => { // 初始值应从快照恢复,而非默认值 expect(result.current[0]).toBe(false); + expect(storageUtil.get).not.toHaveBeenCalled(); }); it('应该从 Chrome Storage 异步加载并覆盖初始值', async () => { @@ -179,5 +180,6 @@ describe('useStorageState', () => { await waitFor(() => { expect(storageUtil.set).not.toHaveBeenCalled(); }); + expect(storageUtil.get).not.toHaveBeenCalled(); }); }); diff --git a/src/utils/chromeStorage.ts b/src/utils/chromeStorage.ts index 4e46085..2eccffa 100644 --- a/src/utils/chromeStorage.ts +++ b/src/utils/chromeStorage.ts @@ -18,13 +18,26 @@ class StorageUtils { key: K, defaultValue?: StorageSchema[K], ): Promise { - const result = await chrome.storage.local.get([key]); + const result = await this.getMany([key]); if (defaultValue !== undefined) { return (result[key] ?? defaultValue) as StorageSchema[K]; } return result[key] as StorageSchema[K] | undefined; } + /** + * 批量获取多个键(单次 IPC) + */ + async getMany( + keys: readonly K[], + ): Promise>> { + if (keys.length === 0) { + return {}; + } + const result = await chrome.storage.local.get([...keys]); + return result as Partial>; + } + /** * 设置值 * @param key diff --git a/src/utils/syncSnapshot.ts b/src/utils/syncSnapshot.ts index 08af5c4..ab764e5 100644 --- a/src/utils/syncSnapshot.ts +++ b/src/utils/syncSnapshot.ts @@ -1,3 +1,14 @@ +/** + * 检查 localStorage 中是否存在指定键的快照 + */ +export function hasSyncSnapshot(key: string): boolean { + try { + return localStorage.getItem(`snapshot/${key}`) !== null; + } catch { + return false; + } +} + /** * 从 localStorage 获取同步快照(用于消除异步加载产生的首屏闪烁) */ diff --git a/src/utils/themeSnapshot.ts b/src/utils/themeSnapshot.ts index 596b34d..da20081 100644 --- a/src/utils/themeSnapshot.ts +++ b/src/utils/themeSnapshot.ts @@ -9,6 +9,15 @@ const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system']; export const isValidThemeMode = (value: unknown): value is ThemeMode => VALID_MODES.includes(value as ThemeMode); +/** 检查 localStorage 中是否存在主题快照 */ +export const hasThemeSyncSnapshot = (): boolean => { + try { + return localStorage.getItem(THEME_MODE_SNAPSHOT_KEY) !== null; + } catch { + return false; + } +}; + /** * 同步读取 localStorage 快照(首屏防闪烁) */ diff --git a/src/utils/useStorageState.ts b/src/utils/useStorageState.ts index 345f827..7277427 100644 --- a/src/utils/useStorageState.ts +++ b/src/utils/useStorageState.ts @@ -7,7 +7,7 @@ import { type SetStateAction, } from 'react'; import { storageUtil } from '@/utils/chromeStorage'; -import { getSyncSnapshot } from '@/utils/syncSnapshot'; +import { getSyncSnapshot, hasSyncSnapshot } from '@/utils/syncSnapshot'; import type { StorageSchema } from '@/types/storage'; export const useStorageState = ( @@ -34,6 +34,14 @@ export const useStorageState = ( let cancelled = false; const loadState = async () => { + if (hasSyncSnapshot(key as string)) { + if (!cancelled) { + setIsInitialized(true); + hasLoadedFromStorage.current = true; + } + return; + } + try { const savedValue = await storageUtil.get(key, defaultValue); if (cancelled) return; diff --git a/src/workers/generator.worker.ts b/src/workers/generator.worker.ts index 2780b6f..1d1c7e4 100644 --- a/src/workers/generator.worker.ts +++ b/src/workers/generator.worker.ts @@ -14,10 +14,14 @@ import type { /** 每生成 N 行让出一次事件循环,以便处理 cancel 消息 */ const YIELD_EVERY = 100; -// 生成结果缓存 -let generatedData: Record[] = []; +/** 当前活跃生成任务 ID;新 start 会 supersede 旧任务 */ +let activeGenerationId: number | null = null; let isCancelled = false; +function shouldAbort(generationId: number): boolean { + return isCancelled || generationId !== activeGenerationId; +} + /** * Worker 消息处理器 */ @@ -27,12 +31,17 @@ self.onmessage = async (e: MessageEvent) => { switch (type) { case 'start': + activeGenerationId = data.payload.generationId; isCancelled = false; await handleStart(data.payload); break; case 'cancel': isCancelled = true; break; + default: { + const _exhaustive: never = type; + return _exhaustive; + } } }; @@ -46,7 +55,7 @@ async function handleStart(payload: { csvMode: boolean; }): Promise { const { generationId, fields, count } = payload; - generatedData = []; + const generatedData: Record[] = []; try { // 验证所有生成器是否存在 @@ -67,15 +76,17 @@ async function handleStart(payload: { // 生成数据 for (let i = 0; i < count; i++) { - if (isCancelled) { - self.postMessage({ - type: 'complete', - generationId, - payload: { - success: false, - error: '生成已取消', - }, - }); + if (shouldAbort(generationId)) { + if (generationId === activeGenerationId) { + self.postMessage({ + type: 'complete', + generationId, + payload: { + success: false, + error: '生成已取消', + }, + }); + } return; } @@ -148,20 +159,26 @@ async function handleStart(payload: { // 定期让出事件循环,使 cancel 消息能被处理 if ((i + 1) % YIELD_EVERY === 0) { await new Promise((resolve) => setTimeout(resolve, 0)); - if (isCancelled) { - self.postMessage({ - type: 'complete', - generationId, - payload: { - success: false, - error: '生成已取消', - }, - }); + if (shouldAbort(generationId)) { + if (generationId === activeGenerationId) { + self.postMessage({ + type: 'complete', + generationId, + payload: { + success: false, + error: '生成已取消', + }, + }); + } return; } } } + if (shouldAbort(generationId)) { + return; + } + const duration = Date.now() - startTime; const successCount = generatedData.filter((item) => Object.keys(item).length > 0).length; diff --git a/tailwind.config.js b/tailwind.config.js index ed91354..42a1f61 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,34 +1 @@ -/** @type {import('tailwindcss').Config} */ -export default { - darkMode: 'class', - content: ['./entrypoints/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: { - colors: { - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - card: 'hsl(var(--card))', - 'card-foreground': 'hsl(var(--card-foreground))', - popover: 'hsl(var(--popover))', - 'popover-foreground': 'hsl(var(--popover-foreground))', - primary: 'hsl(var(--primary))', - 'primary-foreground': 'hsl(var(--primary-foreground))', - secondary: 'hsl(var(--secondary))', - 'secondary-foreground': 'hsl(var(--secondary-foreground))', - muted: 'hsl(var(--muted))', - 'muted-foreground': 'hsl(var(--muted-foreground))', - accent: 'hsl(var(--accent))', - 'accent-foreground': 'hsl(var(--accent-foreground))', - destructive: 'hsl(var(--destructive))', - 'destructive-foreground': 'hsl(var(--destructive-foreground))', - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - }, - borderRadius: { - DEFAULT: 'var(--radius)', - }, - }, - }, - plugins: [], -}; +export { default } from './tailwind.shell.config.js'; diff --git a/tailwind.pages.config.js b/tailwind.pages.config.js new file mode 100644 index 0000000..2466e8d --- /dev/null +++ b/tailwind.pages.config.js @@ -0,0 +1,22 @@ +import { sharedTheme } from './tailwind.shared.js'; + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + './src/pages/**/*.{js,ts,jsx,tsx}', + './src/components/CopyButton.tsx', + './src/components/TextInputArea.tsx', + './src/components/SwitchButtonGroup.tsx', + './src/components/EmptyPlaceholder.tsx', + './src/components/ui/badge.tsx', + './src/components/ui/checkbox.tsx', + './src/components/ui/dialog.tsx', + './src/components/ui/label.tsx', + './src/components/ui/select.tsx', + './src/components/ui/switch.tsx', + './src/styles/pages.css', + ], + theme: sharedTheme, + plugins: [], +}; diff --git a/tailwind.shared.js b/tailwind.shared.js new file mode 100644 index 0000000..f873bba --- /dev/null +++ b/tailwind.shared.js @@ -0,0 +1,29 @@ +/** @type {import('tailwindcss').Config['theme']} */ +export const sharedTheme = { + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + card: 'hsl(var(--card))', + 'card-foreground': 'hsl(var(--card-foreground))', + popover: 'hsl(var(--popover))', + 'popover-foreground': 'hsl(var(--popover-foreground))', + primary: 'hsl(var(--primary))', + 'primary-foreground': 'hsl(var(--primary-foreground))', + secondary: 'hsl(var(--secondary))', + 'secondary-foreground': 'hsl(var(--secondary-foreground))', + muted: 'hsl(var(--muted))', + 'muted-foreground': 'hsl(var(--muted-foreground))', + accent: 'hsl(var(--accent))', + 'accent-foreground': 'hsl(var(--accent-foreground))', + destructive: 'hsl(var(--destructive))', + 'destructive-foreground': 'hsl(var(--destructive-foreground))', + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + }, + borderRadius: { + DEFAULT: 'var(--radius)', + }, + }, +}; diff --git a/tailwind.shell.config.js b/tailwind.shell.config.js new file mode 100644 index 0000000..9acb3f0 --- /dev/null +++ b/tailwind.shell.config.js @@ -0,0 +1,22 @@ +import { sharedTheme } from './tailwind.shared.js'; + +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: [ + './src/entrypoints/**/*.{js,ts,jsx,tsx}', + './src/layout/**/*.{js,ts,jsx,tsx}', + './src/providers/**/*.{js,ts,jsx,tsx}', + './src/components/RouterContainer.tsx', + './src/components/ErrorBoundary.tsx', + './src/components/ErrorFallback.tsx', + './src/components/PageErrorBoundary.tsx', + './src/components/PageSkeleton.tsx', + './src/components/ui/button.tsx', + './src/components/ui/input.tsx', + './src/components/ui/sonner.tsx', + './src/styles/shell.css', + ], + theme: sharedTheme, + plugins: [], +}; diff --git a/wxt.config.ts b/wxt.config.ts index d17a215..d8747fe 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -22,14 +22,10 @@ function manualChunksForHtmlOnly(): Plugin { ) { return 'vendor-react'; } - // 二维码活态感知依赖分流 - if (id.includes('qr-scanner') || id.includes('qrious')) { - return 'vendor-qr'; - } - // 拖拽排序高级组件分流 - if (id.includes('@dnd-kit')) { - return 'vendor-dnd'; - } + + // qrious / qr-scanner / @dnd-kit 不单独拆 vendor chunk: + // 独立 vendor 会与 Vite preload 辅助函数共 chunk,被 App Shell 静态拉取。 + // 这些依赖随各自页面 async chunk 加载即可。 return undefined; }, @@ -77,6 +73,7 @@ export default defineConfig({ vite: () => ({ plugins: [manualChunksForHtmlOnly()], build: { + modulePreload: false, minify: 'terser', terserOptions: { format: { ascii_only: true, comments: false },