From 8ac7a9201693c8593482ba188099acff70a06095 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 26 Jun 2026 11:08:23 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=A4=B1=E8=B4=A5=E3=80=81Worker=20=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E5=93=8D=E5=BA=94=E4=B8=8E=20popup=20=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=A1=B5=E8=A7=A3=E6=9E=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruleStorage.importRules:导出备份含 id 时按新规则导入,避免 save 误判为更新 - 移植 generationId 机制与 Worker 事件循环让出,修复取消后展示错误数据 - 将 getCurrentTab 提取至 chromeTabs,RightClickRestorer 与 messages 复用 lastFocusedWindow 查询 --- .../__tests__/useRightClickRestorer.test.ts | 16 +-- .../useRightClickRestorer.ts | 3 +- .../StorageCleaner/__tests__/index.test.tsx | 8 +- src/pages/StorageCleaner/useStorageCleaner.ts | 2 +- .../hooks/__tests__/useGenerator.test.ts | 117 ++++++++++++++++++ .../TestDataGenerator/hooks/useGenerator.ts | 29 +++-- src/types/testDataGenerator.ts | 20 ++- src/utils/__tests__/chromeTabs.test.ts | 32 ++++- src/utils/__tests__/ruleStorage.test.ts | 83 +++++++++++++ src/utils/chromeTabs.ts | 24 ++++ src/utils/messages.ts | 3 +- src/utils/ruleStorage.ts | 32 +++-- src/utils/storageCleaner.ts | 23 ---- src/workers/generator.worker.ts | 33 ++++- 14 files changed, 363 insertions(+), 62 deletions(-) create mode 100644 src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts create mode 100644 src/utils/__tests__/ruleStorage.test.ts diff --git a/src/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts b/src/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts index 51fbf30..41da76e 100644 --- a/src/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts +++ b/src/pages/RightClickRestorer/__tests__/useRightClickRestorer.test.ts @@ -1,8 +1,11 @@ import { renderHook, act, waitFor } from '@testing-library/react'; import { useRightClickRestorer } from '../useRightClickRestorer'; import { sendMessageToContent } from '@/utils/messages'; +import { getCurrentTab } from '@/utils/chromeTabs'; -const mockTabsQuery = vi.fn(); +vi.mock('@/utils/chromeTabs', () => ({ + getCurrentTab: vi.fn(), +})); vi.mock('@/utils/messages', () => ({ MessageAction: { @@ -14,8 +17,9 @@ vi.mock('@/utils/messages', () => ({ beforeEach(() => { vi.clearAllMocks(); - mockTabsQuery.mockResolvedValue([{ url: 'https://example.com/path' }]); - chrome.tabs.query = mockTabsQuery; + vi.mocked(getCurrentTab).mockResolvedValue({ + url: 'https://example.com/path', + } as chrome.tabs.Tab); vi.mocked(sendMessageToContent).mockResolvedValue({ success: true, restored: false }); }); @@ -31,8 +35,7 @@ describe('useRightClickRestorer', () => { }); it('should mark internal pages as unsupported', async () => { - mockTabsQuery.mockResolvedValue([{ url: 'chrome://newtab/' }]); - chrome.tabs.query = mockTabsQuery; + vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://newtab/' } as chrome.tabs.Tab); const { result } = renderHook(() => useRightClickRestorer()); @@ -59,8 +62,7 @@ describe('useRightClickRestorer', () => { }); it('should not unlock unsupported pages', async () => { - mockTabsQuery.mockResolvedValue([{ url: 'chrome://settings/' }]); - chrome.tabs.query = mockTabsQuery; + vi.mocked(getCurrentTab).mockResolvedValue({ url: 'chrome://settings/' } as chrome.tabs.Tab); const { result } = renderHook(() => useRightClickRestorer()); diff --git a/src/pages/RightClickRestorer/useRightClickRestorer.ts b/src/pages/RightClickRestorer/useRightClickRestorer.ts index b209126..b4d8cff 100644 --- a/src/pages/RightClickRestorer/useRightClickRestorer.ts +++ b/src/pages/RightClickRestorer/useRightClickRestorer.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { getCurrentTab } from '@/utils/chromeTabs'; import { MessageAction, sendMessageToContent } from '@/utils/messages'; import { isUnsupportedPageUrl } from '@/utils/restrictedUrls'; import type { RestorerStatus } from './constants'; @@ -24,7 +25,7 @@ export function useRightClickRestorer(): UseRightClickRestorerReturn { useEffect(() => { const load = async () => { try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = await getCurrentTab(); const url = tab?.url; setDomain(url ? new URL(url).hostname : ''); diff --git a/src/pages/StorageCleaner/__tests__/index.test.tsx b/src/pages/StorageCleaner/__tests__/index.test.tsx index 373bb15..5dd1b6a 100644 --- a/src/pages/StorageCleaner/__tests__/index.test.tsx +++ b/src/pages/StorageCleaner/__tests__/index.test.tsx @@ -2,7 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, it, expect, vi } from 'vitest'; import { browser } from 'wxt/browser'; import Index from '../index'; -import { clearStorage, getCookieSize, getCurrentTab } from '@/utils/storageCleaner'; +import { clearStorage, getCookieSize } from '@/utils/storageCleaner'; +import { getCurrentTab } from '@/utils/chromeTabs'; import { toast } from 'sonner'; // Mock the chrome APIs @@ -13,8 +14,11 @@ vi.mock('@/utils/chromeStorage', () => ({ }, })); -vi.mock('@/utils/storageCleaner', () => ({ +vi.mock('@/utils/chromeTabs', () => ({ getCurrentTab: vi.fn().mockResolvedValue({ id: 1, url: 'https://example.com' }), +})); + +vi.mock('@/utils/storageCleaner', () => ({ getCookieSize: vi.fn().mockResolvedValue(0), getLocalStorageSize: vi.fn().mockResolvedValue(0), getSessionStorageSize: vi.fn().mockResolvedValue(0), diff --git a/src/pages/StorageCleaner/useStorageCleaner.ts b/src/pages/StorageCleaner/useStorageCleaner.ts index 437d1df..55583c6 100644 --- a/src/pages/StorageCleaner/useStorageCleaner.ts +++ b/src/pages/StorageCleaner/useStorageCleaner.ts @@ -10,12 +10,12 @@ import { clearStorage, getCacheStorageSize, getCookieSize, - getCurrentTab, getLocalStorageSize, getOriginStorageEstimate, getServiceWorkerCount, getSessionStorageSize, } from '@/utils/storageCleaner'; +import { getCurrentTab } from '@/utils/chromeTabs'; import { isRestrictedUrl } from '@/utils/restrictedUrls'; import { toast } from 'sonner'; diff --git a/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts b/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts new file mode 100644 index 0000000..7ce233a --- /dev/null +++ b/src/pages/TestDataGenerator/hooks/__tests__/useGenerator.test.ts @@ -0,0 +1,117 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGenerator } from '@/pages/TestDataGenerator/hooks/useGenerator'; +import type { FieldConfig, WorkerResponseMessage } from '@/types/testDataGenerator'; + +const mockField: FieldConfig = { + id: 'field-1', + name: 'username', + generatorId: 'string', + params: {}, + required: true, + nullRate: 0, + unique: false, +}; + +type WorkerListener = (event: MessageEvent) => void; + +class MockWorker { + static instances: MockWorker[] = []; + onmessage: WorkerListener | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + postedMessages: unknown[] = []; + + constructor(_url: URL, _options?: WorkerOptions) { + MockWorker.instances.push(this); + } + + postMessage(message: unknown) { + this.postedMessages.push(message); + } + + terminate() { + const index = MockWorker.instances.indexOf(this); + if (index >= 0) { + MockWorker.instances.splice(index, 1); + } + } + + emit(message: WorkerResponseMessage) { + this.onmessage?.({ data: message } as MessageEvent); + } +} + +describe('useGenerator', () => { + beforeEach(() => { + MockWorker.instances = []; + vi.stubGlobal('Worker', MockWorker); + }); + + it('应忽略过期 generationId 的 complete 消息', async () => { + const { result } = renderHook(() => useGenerator()); + + act(() => { + result.current.generate([mockField], 10); + }); + + const worker = MockWorker.instances[0]; + expect(worker).toBeDefined(); + + act(() => { + result.current.cancel(); + }); + + act(() => { + result.current.generate([mockField], 5); + }); + + act(() => { + worker.emit({ + type: 'complete', + generationId: 1, + payload: { + success: true, + data: [{ username: 'stale' }], + stats: { total: 10, success: 10, failed: 0, duration: 1 }, + }, + }); + }); + + expect(result.current.result).toBeNull(); + expect(result.current.isGenerating).toBe(true); + + act(() => { + worker.emit({ + type: 'complete', + generationId: 3, + payload: { + success: true, + data: [{ username: 'fresh' }], + stats: { total: 5, success: 5, failed: 0, duration: 1 }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.isGenerating).toBe(false); + }); + expect(result.current.result?.data?.[0]).toEqual({ username: 'fresh' }); + }); + + it('cancel 后应发送 cancel 消息并使 generationId 失效', () => { + const { result } = renderHook(() => useGenerator()); + + act(() => { + result.current.generate([mockField], 100); + }); + + const worker = MockWorker.instances[0]; + + act(() => { + result.current.cancel(); + }); + + expect(result.current.isGenerating).toBe(false); + expect(worker.postedMessages).toEqual(expect.arrayContaining([{ type: 'cancel' }])); + }); +}); diff --git a/src/pages/TestDataGenerator/hooks/useGenerator.ts b/src/pages/TestDataGenerator/hooks/useGenerator.ts index 7d38bd5..e1d3221 100644 --- a/src/pages/TestDataGenerator/hooks/useGenerator.ts +++ b/src/pages/TestDataGenerator/hooks/useGenerator.ts @@ -8,7 +8,8 @@ import type { FieldConfig, GenerateResult, GenerateProgress, - WorkerMessage, + WorkerRequestMessage, + WorkerResponseMessage, } from '@/types/testDataGenerator'; export interface UseGeneratorReturn { @@ -35,6 +36,7 @@ export function useGenerator(): UseGeneratorReturn { const [error, setError] = useState(null); const workerRef = useRef(null); + const generationIdRef = useRef(0); // 清理 Worker useEffect(() => { @@ -58,22 +60,30 @@ export function useGenerator(): UseGeneratorReturn { type: 'module', }); - worker.onmessage = (e: MessageEvent) => { + worker.onmessage = (e: MessageEvent) => { const data = e.data; const { type } = data; + if (data.generationId !== generationIdRef.current) { + return; + } + switch (type) { case 'progress': - setProgress(data.payload as GenerateProgress); + setProgress(data.payload); break; case 'complete': setIsGenerating(false); - setResult(data.payload as GenerateResult); + if (data.payload.success) { + setResult(data.payload); + } else if (data.payload.error && data.payload.error !== '生成已取消') { + setError(data.payload.error); + } setProgress(null); break; case 'error': setIsGenerating(false); - setError((data.payload as { error: string }).error); + setError(data.payload.error); setProgress(null); break; } @@ -100,15 +110,17 @@ export function useGenerator(): UseGeneratorReturn { (fields: FieldConfig[], count: number, csvMode = false) => { if (isGenerating) return; + const generationId = ++generationIdRef.current; + setIsGenerating(true); setProgress(null); setResult(null); setError(null); const worker = getWorker(); - const message: WorkerMessage = { + const message: WorkerRequestMessage = { type: 'start', - payload: { fields, count, csvMode }, + payload: { generationId, fields, count, csvMode }, }; worker.postMessage(message); }, @@ -120,7 +132,8 @@ export function useGenerator(): UseGeneratorReturn { */ const cancel = useCallback(() => { if (workerRef.current && isGenerating) { - const message: WorkerMessage = { type: 'cancel' }; + ++generationIdRef.current; + const message: WorkerRequestMessage = { type: 'cancel' }; workerRef.current.postMessage(message); setIsGenerating(false); setProgress(null); diff --git a/src/types/testDataGenerator.ts b/src/types/testDataGenerator.ts index 8d141bd..71a096e 100644 --- a/src/types/testDataGenerator.ts +++ b/src/types/testDataGenerator.ts @@ -156,19 +156,29 @@ export interface GenerateProgress { } /** - * Worker 消息类型 + * Worker 请求消息类型 */ -export type WorkerMessage = +export type WorkerRequestMessage = | { type: 'start'; payload: WorkerStartPayload } - | { type: 'progress'; payload: GenerateProgress } - | { type: 'complete'; payload: GenerateResult } - | { type: 'error'; payload: { error: string } } | { type: 'cancel' }; +/** + * Worker 响应消息类型 + */ +export type WorkerResponseMessage = + | { type: 'progress'; generationId: number; payload: GenerateProgress } + | { type: 'complete'; generationId: number; payload: GenerateResult } + | { type: 'error'; generationId: number; payload: { error: string } }; + +/** @deprecated 使用 WorkerRequestMessage | WorkerResponseMessage */ +export type WorkerMessage = WorkerRequestMessage | WorkerResponseMessage; + /** * Worker 启动参数 */ export interface WorkerStartPayload { + /** 生成任务 ID,用于忽略过期 Worker 响应 */ + generationId: number; /** 字段配置列表 */ fields: FieldConfig[]; /** 生成数量 */ diff --git a/src/utils/__tests__/chromeTabs.test.ts b/src/utils/__tests__/chromeTabs.test.ts index b9aabd6..642a53b 100644 --- a/src/utils/__tests__/chromeTabs.test.ts +++ b/src/utils/__tests__/chromeTabs.test.ts @@ -1,7 +1,37 @@ import { describe, expect, it, vi } from 'vitest'; -import { openExtensionPage } from '@/utils/chromeTabs'; +import { getCurrentTab, openExtensionPage } from '@/utils/chromeTabs'; describe('chromeTabs', () => { + describe('getCurrentTab', () => { + it('应优先使用 lastFocusedWindow 查询当前标签页', async () => { + const mockTab = { id: 1, url: 'https://example.com' }; + (browser.tabs.query as ReturnType).mockResolvedValueOnce([mockTab]); + + const tab = await getCurrentTab(); + + expect(tab).toEqual(mockTab); + expect(browser.tabs.query).toHaveBeenCalledWith({ + active: true, + lastFocusedWindow: true, + }); + }); + + it('当 lastFocusedWindow 无结果时应回退到 currentWindow', async () => { + const fallbackTab = { id: 2, url: 'https://fallback.com' }; + (browser.tabs.query as ReturnType) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([fallbackTab]); + + const tab = await getCurrentTab(); + + expect(tab).toEqual(fallbackTab); + expect(browser.tabs.query).toHaveBeenLastCalledWith({ + active: true, + currentWindow: true, + }); + }); + }); + describe('openExtensionPage', () => { it('应该在新标签页中打开扩展页面', async () => { await openExtensionPage('popup.html'); diff --git a/src/utils/__tests__/ruleStorage.test.ts b/src/utils/__tests__/ruleStorage.test.ts new file mode 100644 index 0000000..7abc509 --- /dev/null +++ b/src/utils/__tests__/ruleStorage.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as ruleStorage from '../ruleStorage'; + +const STORAGE_KEY = 'testDataGenerator_rules'; + +const mockField = { + id: 'field-1', + name: 'username', + generatorId: 'string', + params: {}, + required: true, + nullRate: 0, + unique: false, +}; + +describe('ruleStorage', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('save 在 localStorage 写入失败时应返回 null', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + const result = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + + expect(result).toBeNull(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('update 在 localStorage 写入失败时应返回 null', () => { + const saved = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + expect(saved).not.toBeNull(); + + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + const updated = ruleStorage.update(saved!.id, { name: 'Updated Rule' }); + expect(updated).toBeNull(); + }); + + it('deleteRule 在 localStorage 写入失败时应返回 false', () => { + const saved = ruleStorage.save({ + name: 'Test Rule', + fields: [mockField], + }); + expect(saved).not.toBeNull(); + + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('QuotaExceededError'); + }); + + expect(ruleStorage.deleteRule(saved!.id)).toBe(false); + expect(ruleStorage.getById(saved!.id)).toBeDefined(); + }); + + it('importRules 应能恢复导出的规则备份', () => { + const saved = ruleStorage.save({ + name: 'Backup Rule', + fields: [mockField], + }); + expect(saved).not.toBeNull(); + + const exported = ruleStorage.exportRules(); + ruleStorage.clear(); + + const result = ruleStorage.importRules(exported); + + expect(result.success).toBe(1); + expect(result.failed).toBe(0); + expect(ruleStorage.getAll()).toHaveLength(1); + expect(ruleStorage.getAll()[0].name).toBe('Backup Rule'); + }); +}); diff --git a/src/utils/chromeTabs.ts b/src/utils/chromeTabs.ts index 451a085..b0fab28 100644 --- a/src/utils/chromeTabs.ts +++ b/src/utils/chromeTabs.ts @@ -1,7 +1,31 @@ +import { browser } from 'wxt/browser'; + /** * Chrome 标签页相关工具函数 */ +/** + * 获取用户当前正在浏览的标签页。 + * popup 中 `currentWindow` 指向弹窗自身,需优先使用 `lastFocusedWindow`。 + */ +export async function getCurrentTab() { + const [tab] = await browser.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + + if (tab) { + return tab; + } + + const [fallbackTab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + return fallbackTab; +} + /** * 在新标签页中打开扩展页面 * @param page - 扩展页面路径(如 'popup.html') diff --git a/src/utils/messages.ts b/src/utils/messages.ts index b1f9bcb..09c12d7 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,4 +1,5 @@ import { defineExtensionMessaging } from '@webext-core/messaging'; +import { getCurrentTab } from '@/utils/chromeTabs'; export enum MessageAction { SIDE_PANEL_STATE_CHANGED = 'sidePanelStateChanged', @@ -38,7 +39,7 @@ export async function sendMessageToContent( : [data: Parameters[0]] ): Promise> { try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const tab = await getCurrentTab(); if (!tab?.id) { console.warn(`[Messaging] 无法获取当前标签页,无法发送动作: ${action}`); return { success: false, message: '无法获取当前标签页' } as ReturnType; diff --git a/src/utils/ruleStorage.ts b/src/utils/ruleStorage.ts index d39c107..9ec4af5 100644 --- a/src/utils/ruleStorage.ts +++ b/src/utils/ruleStorage.ts @@ -78,7 +78,9 @@ export function save( useCount: 0, }; rules.unshift(newRule); - setAll(rules); + if (!setAll(rules)) { + return null; + } return newRule; } @@ -95,7 +97,9 @@ export function save( updatedAt: now, }; rules[index] = updatedRule; - setAll(rules); + if (!setAll(rules)) { + return null; + } return updatedRule; } @@ -117,7 +121,9 @@ export function update(id: string, updates: Partial): DataRule | null updatedAt: Date.now(), }; rules[index] = updatedRule; - setAll(rules); + if (!setAll(rules)) { + return null; + } return updatedRule; } @@ -132,8 +138,7 @@ export function deleteRule(id: string): boolean { return false; } rules.splice(index, 1); - setAll(rules); - return true; + return setAll(rules); } /** @@ -165,7 +170,9 @@ export function duplicate(id: string, copySuffix = '(副本)'): DataRule | n const rules = getAll(); rules.unshift(newRule); - setAll(rules); + if (!setAll(rules)) { + return null; + } return newRule; } @@ -241,7 +248,14 @@ export function importRules(jsonString: string): { continue; } - const saved = save(item as Omit); + const ruleItem = item as DataRule; + const existsLocally = Boolean(ruleItem.id && getById(ruleItem.id)); + const saved = save({ + name: ruleItem.name, + description: ruleItem.description, + fields: ruleItem.fields, + ...(existsLocally ? { id: ruleItem.id } : {}), + }); if (saved) { result.success++; } else { @@ -267,11 +281,13 @@ export function clear(): void { /** * 保存所有规则到存储 */ -function setAll(rules: DataRule[]): void { +function setAll(rules: DataRule[]): boolean { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(rules)); + return true; } catch (error) { console.error('[ruleStorage] 保存规则失败:', error); + return false; } } diff --git a/src/utils/storageCleaner.ts b/src/utils/storageCleaner.ts index 0a23b65..7d6eb00 100644 --- a/src/utils/storageCleaner.ts +++ b/src/utils/storageCleaner.ts @@ -11,29 +11,6 @@ import { browser } from 'wxt/browser'; type CleanScriptResult = IndexedDBCleanResult; -export async function getCurrentTab() { - // For popup pages, we need to get the active tab from the browser window that triggered the popup. - // We should ONLY care about the currently active tab in the last focused window. - // If it's a restricted URL, we return it anyway and let the caller handle the error display. - - const [tab] = await browser.tabs.query({ - active: true, - lastFocusedWindow: true, - }); - - if (tab) { - return tab; - } - - // Fallback for cases where lastFocusedWindow might not work as expected (e.g. certain sidepanel scenarios) - const [fallbackTab] = await browser.tabs.query({ - active: true, - currentWindow: true, - }); - - return fallbackTab; -} - export async function getCookieSize(url: string): Promise { try { const cookies = await browser.cookies.getAll({ url }); diff --git a/src/workers/generator.worker.ts b/src/workers/generator.worker.ts index 5795cee..2780b6f 100644 --- a/src/workers/generator.worker.ts +++ b/src/workers/generator.worker.ts @@ -6,11 +6,14 @@ import { getGeneratorById } from '@/lib/generators'; import type { FieldConfig, - WorkerMessage, + WorkerRequestMessage, GenerateResult, GenerateProgress, } from '@/types/testDataGenerator'; +/** 每生成 N 行让出一次事件循环,以便处理 cancel 消息 */ +const YIELD_EVERY = 100; + // 生成结果缓存 let generatedData: Record[] = []; let isCancelled = false; @@ -18,7 +21,7 @@ let isCancelled = false; /** * Worker 消息处理器 */ -self.onmessage = async (e: MessageEvent) => { +self.onmessage = async (e: MessageEvent) => { const data = e.data; const { type } = data; @@ -37,11 +40,12 @@ self.onmessage = async (e: MessageEvent) => { * 处理开始生成消息 */ async function handleStart(payload: { + generationId: number; fields: FieldConfig[]; count: number; csvMode: boolean; }): Promise { - const { fields, count } = payload; + const { generationId, fields, count } = payload; generatedData = []; try { @@ -51,6 +55,7 @@ async function handleStart(payload: { if (!generator) { self.postMessage({ type: 'error', + generationId, payload: { error: `生成器 "${field.generatorId}" 不存在` }, }); return; @@ -65,6 +70,7 @@ async function handleStart(payload: { if (isCancelled) { self.postMessage({ type: 'complete', + generationId, payload: { success: false, error: '生成已取消', @@ -136,7 +142,23 @@ async function handleStart(payload: { total: count, estimatedTimeLeft: Math.round(((Date.now() - startTime) / (i + 1)) * (count - i - 1)), }; - self.postMessage({ type: 'progress', payload: progress }); + self.postMessage({ type: 'progress', generationId, payload: progress }); + } + + // 定期让出事件循环,使 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: '生成已取消', + }, + }); + return; + } } } @@ -155,10 +177,11 @@ async function handleStart(payload: { }, }; - self.postMessage({ type: 'complete', payload: result }); + self.postMessage({ type: 'complete', generationId, payload: result }); } catch (error) { self.postMessage({ type: 'error', + generationId, payload: { error: `生成失败: ${error}` }, }); }