feat: support crash recovery tasks

这个提交包含在:
Zain 2026-02-18 22:43:38 +08:00
父节点 4750aca415
当前提交 a13175441c
共有 3 个文件被更改,包括 131 次插入 和 203 次删除

查看文件

@ -1,5 +1,5 @@
import { PlatformAdapter } from '../types/platform'; import { PlatformAdapter } from '../types/platform';
import { OperationState } from '../types/core'; import { OperationState, Task } from '../types/core';
import { storage } from '../utils/storage'; import { storage } from '../utils/storage';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { BatchExecutor } from './executor'; import { BatchExecutor } from './executor';
@ -46,6 +46,7 @@ export class CrashRecoveryManager {
rule: state.rule, rule: state.rule,
completed: state.completed, completed: state.completed,
failed: state.failed, failed: state.failed,
tasks: state.tasks,
}; };
await storage.set(this.STORAGE_KEY, data); await storage.set(this.STORAGE_KEY, data);
@ -76,15 +77,18 @@ export class CrashRecoveryManager {
} }
// 检查是否有未完成的文件 // 检查是否有未完成的文件
const totalItems = (savedData.tasks && savedData.tasks.length > 0)
? savedData.tasks.length
: savedData.files.length;
const totalCompleted = savedData.completed.length + savedData.failed.length; const totalCompleted = savedData.completed.length + savedData.failed.length;
if (totalCompleted >= savedData.files.length) { if (totalCompleted >= totalItems) {
await this.clearOperationState(); await this.clearOperationState();
return null; return null;
} }
logger.info('Recoverable operation found', { logger.info('Recoverable operation found', {
age: `${ageMinutes.toFixed(1)} minutes`, age: `${ageMinutes.toFixed(1)} minutes`,
files: savedData.files.length, files: totalItems,
completed: savedData.completed.length, completed: savedData.completed.length,
failed: savedData.failed.length, failed: savedData.failed.length,
}); });
@ -111,7 +115,24 @@ export class CrashRecoveryManager {
): Promise<void> { ): Promise<void> {
// 过滤出未完成的文件 // 过滤出未完成的文件
const completedSet = new Set([...savedState.completed, ...savedState.failed]); const completedSet = new Set([...savedState.completed, ...savedState.failed]);
const pendingFiles = savedState.files.filter((_file: any, index: number) => !completedSet.has(index)); const hasTasks = Array.isArray(savedState.tasks) && savedState.tasks.length > 0;
const indexByFileId = new Map<string, number>();
let pendingFiles: any[] = [];
let pendingTasks: Task[] | undefined;
if (hasTasks) {
savedState.tasks!.forEach((task) => {
indexByFileId.set(task.file.id, task.index);
});
pendingTasks = savedState.tasks!.filter((task) => !completedSet.has(task.index));
pendingFiles = pendingTasks.map((task) => task.file);
} else {
savedState.files.forEach((file: any, index: number) => {
indexByFileId.set(file.id, index);
});
pendingFiles = savedState.files.filter((_file: any, index: number) => !completedSet.has(index));
}
if (pendingFiles.length === 0) { if (pendingFiles.length === 0) {
await this.clearOperationState(); await this.clearOperationState();
@ -129,21 +150,25 @@ export class CrashRecoveryManager {
const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, { const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, {
requestInterval: requestInterval ?? 800, requestInterval: requestInterval ?? 800,
maxConcurrent, maxConcurrent,
tasks: pendingTasks,
onProgress: (progress) => { onProgress: (progress) => {
// 转发进度事件 // 转发进度事件
onProgress?.(progress); onProgress?.(progress);
// 更新进度到存储 if (!progress.fileId) {
const currentIndex = savedState.files.findIndex( return;
(f: any) => f.id === pendingFiles[progress.completed - 1]?.id }
);
if (currentIndex !== -1) { const currentIndex = indexByFileId.get(progress.fileId);
const isFailed = progress.failed > 0; if (currentIndex === undefined) {
if (isFailed) { return;
this.markAsFailed(currentIndex); }
} else {
this.markAsCompleted(currentIndex); const isFailed = progress.status === 'failed';
} if (isFailed) {
this.markAsFailed(currentIndex);
} else {
this.markAsCompleted(currentIndex);
} }
}, },
onComplete: async (results) => { onComplete: async (results) => {
@ -214,14 +239,17 @@ export class CrashRecoveryManager {
* @returns 用户是否选择恢复 * @returns 用户是否选择恢复
*/ */
showRecoveryDialog(savedState: OperationState): boolean { showRecoveryDialog(savedState: OperationState): boolean {
const totalItems = (savedState.tasks && savedState.tasks.length > 0)
? savedState.tasks.length
: savedState.files.length;
const totalCompleted = savedState.completed.length + savedState.failed.length; const totalCompleted = savedState.completed.length + savedState.failed.length;
const remaining = savedState.files.length - totalCompleted; const remaining = totalItems - totalCompleted;
const ageMinutes = Math.floor((Date.now() - savedState.timestamp) / 60000); const ageMinutes = Math.floor((Date.now() - savedState.timestamp) / 60000);
const message = const message =
`检测到未完成的批量重命名操作:\n\n` + `检测到未完成的批量重命名操作:\n\n` +
`平台: ${savedState.platform}\n` + `平台: ${savedState.platform}\n` +
`总文件数: ${savedState.files.length}\n` + `总文件数: ${totalItems}\n` +
`已完成: ${savedState.completed.length}\n` + `已完成: ${savedState.completed.length}\n` +
`已失败: ${savedState.failed.length}\n` + `已失败: ${savedState.failed.length}\n` +
`待完成: ${remaining}\n` + `待完成: ${remaining}\n` +

查看文件

@ -58,6 +58,8 @@ export interface OperationState {
completed: number[]; completed: number[];
/** 失败的索引 */ /** 失败的索引 */
failed: number[]; failed: number[];
/** 自定义任务列表(可选) */
tasks?: Task[];
} }
/** /**

查看文件

@ -1,204 +1,102 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CrashRecoveryManager } from '../../src/core/crash-recovery'; import { CrashRecoveryManager } from '../../src/core/crash-recovery';
import { OperationState } from '../../src/types/core';
import { storage } from '../../src/utils/storage';
// Mock storage vi.mock('../../src/rules/rule-factory', () => ({
vi.mock('../../src/utils/storage', () => ({ RuleFactory: { create: () => ({ execute: (name: string) => `rule_${name}` }) },
storage: {
set: vi.fn(),
get: vi.fn(),
remove: vi.fn(),
},
})); }));
// Mock logger type StorageShape = Record<string, any>;
vi.mock('../../src/utils/logger', () => ({
logger: { class MockAdapter {
debug: vi.fn(), platform = 'quark' as const;
info: vi.fn(), renameCalls: string[] = [];
warn: vi.fn(),
error: vi.fn(), async renameFile(_id: string, newName: string) {
}, this.renameCalls.push(newName);
})); return { success: true, newName };
}
async getSelectedFiles() {
return [];
}
async getAllFiles() {
return [];
}
async checkNameConflict() {
return false;
}
async getFileInfo(id: string) {
return { id, name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 };
}
getConfig() {
return { platform: 'quark', requestInterval: 0, maxRetries: 1 };
}
}
describe('CrashRecoveryManager', () => { describe('CrashRecoveryManager', () => {
let recovery: CrashRecoveryManager; let storageData: StorageShape;
let mockState: OperationState;
beforeEach(() => { beforeEach(() => {
recovery = new CrashRecoveryManager(); storageData = {};
mockState = {
timestamp: Date.now(), const storageLocal = {
platform: 'quark', set: vi.fn(async (items: StorageShape) => {
files: [ Object.assign(storageData, items);
{ id: '1', name: 'file1.txt' }, }),
{ id: '2', name: 'file2.txt' }, get: vi.fn(async (key: string | null) => {
{ id: '3', name: 'file3.txt' }, if (!key) return storageData;
], return { [key]: storageData[key] };
rule: { type: 'prefix', params: { prefix: 'test_' } }, }),
completed: [0], remove: vi.fn(async (key: string) => {
failed: [], delete storageData[key];
}),
clear: vi.fn(async () => {
storageData = {};
}),
}; };
// 重置mocks global.chrome = {
vi.clearAllMocks(); storage: { local: storageLocal },
} as any;
global.localStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
key: vi.fn(),
length: 0,
} as any;
}); });
describe('saveOperationState', () => { it('resumeOperation should use saved tasks when provided', async () => {
it('应该保存操作状态', async () => { const recovery = new CrashRecoveryManager();
await recovery.saveOperationState(mockState); const adapter = new MockAdapter();
expect(storage.set).toHaveBeenCalledWith( await recovery.saveOperationState({
'rename_operation_state', platform: 'quark',
expect.objectContaining({ files: [
platform: 'quark', { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 },
files: mockState.files, ],
rule: mockState.rule, rule: { type: 'prefix', params: { prefix: 'x_' } },
completed: [0], completed: [],
failed: [], failed: [],
}) tasks: [
); {
}); file: { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 },
newName: 'custom.txt',
index: 0,
},
],
} as any);
it('保存时应该包含时间戳', async () => { const saved = await recovery.checkRecoverableOperation();
await recovery.saveOperationState(mockState); await recovery.resumeOperation(saved!, adapter as any);
expect(storage.set).toHaveBeenCalledWith( expect(adapter.renameCalls[0]).toBe('custom.txt');
'rename_operation_state',
expect.objectContaining({
timestamp: expect.any(Number),
})
);
});
});
describe('checkRecoverableOperation', () => {
it('没有保存状态时应该返回null', async () => {
vi.mocked(storage.get).mockResolvedValue(null);
const result = await recovery.checkRecoverableOperation();
expect(result).toBeNull();
});
it('状态超过30分钟应该返回null并清理', async () => {
const oldState = {
...mockState,
timestamp: Date.now() - 31 * 60 * 1000, // 31分钟前
};
vi.mocked(storage.get).mockResolvedValue(oldState);
const result = await recovery.checkRecoverableOperation();
expect(result).toBeNull();
expect(storage.remove).toHaveBeenCalledWith('rename_operation_state');
});
it('30分钟内的状态应该返回', async () => {
const recentState = {
...mockState,
timestamp: Date.now() - 10 * 60 * 1000, // 10分钟前
};
vi.mocked(storage.get).mockResolvedValue(recentState);
const result = await recovery.checkRecoverableOperation();
expect(result).not.toBeNull();
expect(result?.platform).toBe('quark');
});
it('已完成的操作应该返回null并清理', async () => {
const completedState = {
...mockState,
completed: [0, 1, 2], // 所有文件都完成
};
vi.mocked(storage.get).mockResolvedValue(completedState);
const result = await recovery.checkRecoverableOperation();
expect(result).toBeNull();
expect(storage.remove).toHaveBeenCalled();
});
});
describe('clearOperationState', () => {
it('应该清除保存的状态', async () => {
await recovery.clearOperationState();
expect(storage.remove).toHaveBeenCalledWith('rename_operation_state');
});
});
describe('getPendingFiles', () => {
it('应该返回未完成的文件', () => {
const pendingFiles = recovery.getPendingFiles(mockState);
expect(pendingFiles.length).toBe(2); // file2和file3
expect(pendingFiles[0].id).toBe('2');
expect(pendingFiles[1].id).toBe('3');
});
it('所有文件完成时应该返回空数组', () => {
const completedState = {
...mockState,
completed: [0, 1, 2],
};
const pendingFiles = recovery.getPendingFiles(completedState);
expect(pendingFiles.length).toBe(0);
});
});
describe('markAsCompleted', () => {
it('应该标记文件为已完成', async () => {
vi.mocked(storage.get).mockResolvedValue(mockState);
await recovery.markAsCompleted(1);
expect(storage.set).toHaveBeenCalledWith(
'rename_operation_state',
expect.objectContaining({
completed: [0, 1],
})
);
});
});
describe('markAsFailed', () => {
it('应该标记文件为失败', async () => {
vi.mocked(storage.get).mockResolvedValue(mockState);
await recovery.markAsFailed(1);
expect(storage.set).toHaveBeenCalledWith(
'rename_operation_state',
expect.objectContaining({
failed: [1],
})
);
});
});
describe('showRecoveryDialog', () => {
it('应该显示包含操作信息的对话框', () => {
// Mock confirm
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
const result = recovery.showRecoveryDialog(mockState);
expect(result).toBe(true);
expect(confirmSpy).toHaveBeenCalled();
// 检查对话框消息包含关键信息
const message = confirmSpy.mock.calls[0][0];
expect(message).toContain('quark');
expect(message).toContain('3'); // 总文件数
expect(message).toContain('1'); // 已完成
expect(message).toContain('2'); // 待完成
confirmSpy.mockRestore();
});
}); });
}); });