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 { OperationState } from '../types/core';
import { OperationState, Task } from '../types/core';
import { storage } from '../utils/storage';
import { logger } from '../utils/logger';
import { BatchExecutor } from './executor';
@ -46,6 +46,7 @@ export class CrashRecoveryManager {
rule: state.rule,
completed: state.completed,
failed: state.failed,
tasks: state.tasks,
};
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;
if (totalCompleted >= savedData.files.length) {
if (totalCompleted >= totalItems) {
await this.clearOperationState();
return null;
}
logger.info('Recoverable operation found', {
age: `${ageMinutes.toFixed(1)} minutes`,
files: savedData.files.length,
files: totalItems,
completed: savedData.completed.length,
failed: savedData.failed.length,
});
@ -111,7 +115,24 @@ export class CrashRecoveryManager {
): Promise<void> {
// 过滤出未完成的文件
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) {
await this.clearOperationState();
@ -129,22 +150,26 @@ export class CrashRecoveryManager {
const executor = new BatchExecutor(pendingFiles, savedState.rule, adapter, {
requestInterval: requestInterval ?? 800,
maxConcurrent,
tasks: pendingTasks,
onProgress: (progress) => {
// 转发进度事件
onProgress?.(progress);
// 更新进度到存储
const currentIndex = savedState.files.findIndex(
(f: any) => f.id === pendingFiles[progress.completed - 1]?.id
);
if (currentIndex !== -1) {
const isFailed = progress.failed > 0;
if (!progress.fileId) {
return;
}
const currentIndex = indexByFileId.get(progress.fileId);
if (currentIndex === undefined) {
return;
}
const isFailed = progress.status === 'failed';
if (isFailed) {
this.markAsFailed(currentIndex);
} else {
this.markAsCompleted(currentIndex);
}
}
},
onComplete: async (results) => {
// 完成后清理状态
@ -214,14 +239,17 @@ export class CrashRecoveryManager {
* @returns 用户是否选择恢复
*/
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 remaining = savedState.files.length - totalCompleted;
const remaining = totalItems - totalCompleted;
const ageMinutes = Math.floor((Date.now() - savedState.timestamp) / 60000);
const message =
`检测到未完成的批量重命名操作:\n\n` +
`平台: ${savedState.platform}\n` +
`总文件数: ${savedState.files.length}\n` +
`总文件数: ${totalItems}\n` +
`已完成: ${savedState.completed.length}\n` +
`已失败: ${savedState.failed.length}\n` +
`待完成: ${remaining}\n` +

查看文件

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

查看文件

@ -1,204 +1,102 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { CrashRecoveryManager } from '../../src/core/crash-recovery';
import { OperationState } from '../../src/types/core';
import { storage } from '../../src/utils/storage';
// Mock storage
vi.mock('../../src/utils/storage', () => ({
storage: {
set: vi.fn(),
get: vi.fn(),
remove: vi.fn(),
},
vi.mock('../../src/rules/rule-factory', () => ({
RuleFactory: { create: () => ({ execute: (name: string) => `rule_${name}` }) },
}));
// Mock logger
vi.mock('../../src/utils/logger', () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
type StorageShape = Record<string, any>;
class MockAdapter {
platform = 'quark' as const;
renameCalls: string[] = [];
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', () => {
let recovery: CrashRecoveryManager;
let mockState: OperationState;
let storageData: StorageShape;
beforeEach(() => {
recovery = new CrashRecoveryManager();
mockState = {
timestamp: Date.now(),
storageData = {};
const storageLocal = {
set: vi.fn(async (items: StorageShape) => {
Object.assign(storageData, items);
}),
get: vi.fn(async (key: string | null) => {
if (!key) return storageData;
return { [key]: storageData[key] };
}),
remove: vi.fn(async (key: string) => {
delete storageData[key];
}),
clear: vi.fn(async () => {
storageData = {};
}),
};
global.chrome = {
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;
});
it('resumeOperation should use saved tasks when provided', async () => {
const recovery = new CrashRecoveryManager();
const adapter = new MockAdapter();
await recovery.saveOperationState({
platform: 'quark',
files: [
{ id: '1', name: 'file1.txt' },
{ id: '2', name: 'file2.txt' },
{ id: '3', name: 'file3.txt' },
{ id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 },
],
rule: { type: 'prefix', params: { prefix: 'test_' } },
completed: [0],
rule: { type: 'prefix', params: { prefix: 'x_' } },
completed: [],
failed: [],
};
tasks: [
{
file: { id: '1', name: 'a.txt', ext: '.txt', parentId: '0', size: 1, mtime: 1 },
newName: 'custom.txt',
index: 0,
},
],
} as any);
// 重置mocks
vi.clearAllMocks();
});
const saved = await recovery.checkRecoverableOperation();
await recovery.resumeOperation(saved!, adapter as any);
describe('saveOperationState', () => {
it('应该保存操作状态', async () => {
await recovery.saveOperationState(mockState);
expect(storage.set).toHaveBeenCalledWith(
'rename_operation_state',
expect.objectContaining({
platform: 'quark',
files: mockState.files,
rule: mockState.rule,
completed: [0],
failed: [],
})
);
});
it('保存时应该包含时间戳', async () => {
await recovery.saveOperationState(mockState);
expect(storage.set).toHaveBeenCalledWith(
'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();
});
expect(adapter.renameCalls[0]).toBe('custom.txt');
});
});