cloud-drive-renamer/docs/plans/2026-03-18-last-batch-undo-implementation-plan.md
Zain bf75cdcb10 feat: 为批量重命名面板增加撤销最近一次操作功能
- 在平台适配器中统一暴露 getCurrentDirectoryKey() 方法,用于确定当前目录范围
- 新增 LastRenameOperation 数据模型和核心操作函数,支持单条撤销记录的创建、合并和范围校验
- 在 file-selector-panel 中集成撤销逻辑,支持执行后保存记录、retry 成功后合并记录以及撤销操作
- 在 config-panel 底部操作区新增撤销按钮,根据当前目录范围动态显示/隐藏
- 添加多语言支持,包含撤销相关文案
- 添加单元测试和集成测试,确保撤销功能的正确性和稳定性
2026-03-18 23:14:53 +08:00

443 行
11 KiB
Markdown

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

# Last Batch Undo Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为批量重命名面板增加“仅撤销当前目录最近一次批量重命名”的能力,并让 retry 成功项并入同一条可撤销记录。
**Architecture:** 在平台适配器层统一暴露 `getCurrentDirectoryKey()`,在 `file-selector-panel` 维护单条 `LastRenameOperation`,通过纯函数帮助模块完成记录创建、合并、scope 校验和失败项收窄,再由 `config-panel` 提供单按钮 UI。执行与撤销都复用 `BatchExecutor`,避免重复实现节流、进度和错误汇总逻辑。
**Tech Stack:** TypeScript, Lit Web Components, Vitest, Playwright(回归可选)
---
### Task 1: 增加统一目录 scope API
**Files:**
- Modify: `src/types/platform.ts`
- Modify: `src/adapters/base/adapter.interface.ts`
- Modify: `src/adapters/quark/quark.ts`
- Modify: `src/adapters/aliyun/aliyun-adapter.ts`
- Modify: `src/adapters/baidu/baidu-adapter.ts`
- Modify: `tests/unit/executor.test.ts`
- Modify: `tests/integration/batch-execution.test.ts`
- Modify: `tests/integration/folder-batch-execution.test.ts`
- Modify: `tests/performance/benchmark.test.ts`
**Step 1: 写一个最小失败测试(或先让 typecheck 暴露接口缺失)**
在 `tests/unit/executor.test.ts` 的 mock adapter 中先补一条注释,准备添加:
```ts
getCurrentDirectoryKey(): string {
return 'root';
}
```
**Step 2: 修改平台接口与抽象基类**
在 `src/types/platform.ts` 增加:
```ts
getCurrentDirectoryKey(): string;
```
在 `src/adapters/base/adapter.interface.ts` 增加抽象声明:
```ts
abstract getCurrentDirectoryKey(): string;
```
**Step 3: 在三个平台适配器中实现该方法**
目标实现:
```ts
getCurrentDirectoryKey(): string {
return this.getCurrentFolderId(); // Quark
}
```
```ts
getCurrentDirectoryKey(): string {
return this.getParentIdFromUrl(); // Aliyun
}
```
```ts
getCurrentDirectoryKey(): string {
return this.getCurrentPath(); // Baidu
}
```
**Step 4: 更新所有测试 mock adapter**
给所有 `implements PlatformAdapter` 的 mock 增加:
```ts
getCurrentDirectoryKey(): string {
return 'root';
}
```
**Step 5: 运行定向类型检查/测试**
Run:
```bash
npm run typecheck
npx vitest run tests/unit/executor.test.ts tests/integration/batch-execution.test.ts
```
Expected: 新接口编译通过,mock 不再缺少 `getCurrentDirectoryKey()`。
**Step 6: Commit**
```bash
git add src/types/platform.ts src/adapters/base/adapter.interface.ts src/adapters/quark/quark.ts src/adapters/aliyun/aliyun-adapter.ts src/adapters/baidu/baidu-adapter.ts tests/unit/executor.test.ts tests/integration/batch-execution.test.ts tests/integration/folder-batch-execution.test.ts tests/performance/benchmark.test.ts
git commit -m "refactor: expose current directory scope on adapters"
```
### Task 2: 提取单条 undo 记录帮助模块(TDD)
**Files:**
- Create: `src/types/undo.ts`
- Create: `src/core/last-rename-operation.ts`
- Create: `tests/unit/last-rename-operation.test.ts`
**Step 1: 先写失败测试**
在 `tests/unit/last-rename-operation.test.ts` 写出以下核心用例:
```ts
it('creates a last rename operation from success results', () => {
const op = createLastRenameOperation('quark', 'dir-1', [
{ fileId: '1', original: 'a.txt', renamed: 'b.txt', index: 0 },
]);
expect(op?.items).toHaveLength(1);
expect(op?.items[0].original).toBe('a.txt');
});
it('merges retry success items into the existing operation', () => {
const merged = mergeLastRenameOperation(existing, [
{ fileId: '2', original: 'c.txt', renamed: 'd.txt', index: 1 },
]);
expect(merged.items.map(i => i.fileId)).toEqual(['1', '2']);
});
it('shrinks the operation to failed undo items only', () => {
const next = retainFailedUndoItems(existing, new Set(['2']));
expect(next?.items.map(i => i.fileId)).toEqual(['2']);
});
```
**Step 2: 运行测试确认失败**
Run:
```bash
npx vitest run tests/unit/last-rename-operation.test.ts
```
Expected: FAIL,提示模块或导出不存在。
**Step 3: 实现最小类型与纯函数**
在 `src/types/undo.ts` 增加:
```ts
export interface UndoRenameItem {
fileId: string;
original: string;
renamed: string;
index: number;
}
export interface LastRenameOperation {
platform: PlatformName;
directoryKey: string;
createdAt: number;
updatedAt: number;
items: UndoRenameItem[];
}
```
在 `src/core/last-rename-operation.ts` 实现:
- `createLastRenameOperation(...)`
- `mergeLastRenameOperation(...)`
- `isLastRenameOperationInScope(...)`
- `retainFailedUndoItems(...)`
**Step 4: 再次运行测试**
Run:
```bash
npx vitest run tests/unit/last-rename-operation.test.ts
```
Expected: PASS
**Step 5: Commit**
```bash
git add src/types/undo.ts src/core/last-rename-operation.ts tests/unit/last-rename-operation.test.ts
git commit -m "test: cover last rename undo operation helpers"
```
### Task 3: 在 file-selector-panel 中接入记录、retry 合并与 undo 主逻辑
**Files:**
- Modify: `src/content/components/file-selector-panel.ts`
- Modify: `src/types/core.ts`(如需补充 undo 结果或辅助类型,否则保持不动)
**Step 1: 先写失败测试或至少列出要补的帮助方法**
如果暂不引入组件测试,先在代码注释中列出 4 个目标方法:
```ts
private getCurrentUndoScope(): { platform: PlatformName; directoryKey: string }
private clearLastRenameOperationIfOutOfScope(): void
private updateLastRenameOperationFromExecute(results: BatchResults): void
private async handleUndoLastRename(): Promise<void>
```
**Step 2: 增加状态字段**
在 `file-selector-panel.ts` 增加:
```ts
@state()
private lastRenameOperation: LastRenameOperation | null = null;
@state()
private undoBusy = false;
```
**Step 3: 在关键入口先做 scope 校验**
在这些方法开头调用:
```ts
this.clearLastRenameOperationIfOutOfScope();
```
目标入口:
- `updated()`(当 `open` 变为 true 时)
- `handleExecute()`
- `handleRetryFailed()`
- `handleUndoLastRename()`
**Step 4: 在主执行后创建单条记录**
在 `handleExecute()` 成功路径里:
```ts
if (results.success.length > 0) {
this.lastRenameOperation = createLastRenameOperation(
this.adapter.platform,
this.adapter.getCurrentDirectoryKey(),
results.success
);
}
```
**Step 5: 在 retry 成功后合并记录**
在 `handleRetryFailed()` 成功路径里:
```ts
if (results.success.length > 0 && this.lastRenameOperation) {
this.lastRenameOperation = mergeLastRenameOperation(
this.lastRenameOperation,
this.adapter.platform,
this.adapter.getCurrentDirectoryKey(),
results.success
);
}
```
**Step 6: 实现 undo 执行**
核心逻辑:
```ts
const tasks = this.lastRenameOperation.items.map((item, index) => ({
file: this.allFiles.find(file => file.id === item.fileId) ?? {
id: item.fileId,
name: item.renamed,
ext: parseFileName(item.renamed).ext,
parentId: '',
size: 0,
mtime: Date.now(),
},
newName: item.original,
index,
}));
```
然后复用 `BatchExecutor`:
```ts
const executor = new BatchExecutor(tasks.map(t => t.file), this.ruleConfig, this.adapter, {
requestInterval: this.adapter.getConfig().requestInterval,
maxConcurrent: this.adapter.getConfig().maxConcurrent,
tasks,
onProgress: (progress) => this.handleProgress(progress),
});
```
**Step 7: 处理撤销后的记录状态**
- 全部成功:`this.lastRenameOperation = null`
- 部分失败:只保留失败 fileId 对应的项
```ts
const failedIds = new Set(results.failed.map(item => item.fileId));
this.lastRenameOperation = retainFailedUndoItems(this.lastRenameOperation, failedIds);
```
**Step 8: 运行定向测试与类型检查**
Run:
```bash
npm run typecheck
npx vitest run tests/unit/last-rename-operation.test.ts tests/unit/executor.test.ts
```
Expected: PASS
**Step 9: Commit**
```bash
git add src/content/components/file-selector-panel.ts src/types/core.ts
git commit -m "feat: add single-operation undo flow"
```
### Task 4: 在 config-panel 加入撤销按钮和多语言文案
**Files:**
- Modify: `src/content/components/config-panel.ts`
- Modify: `src/locales/zh_CN.json`
- Modify: `src/locales/en.json`
- Modify: `src/locales/zh_TW.json`
**Step 1: 先加 props 和事件**
在 `config-panel.ts` 增加:
```ts
@property({ type: Boolean }) canUndo = false;
@property({ type: Boolean }) undoBusy = false;
```
以及:
```ts
private handleUndo(): void {
this.dispatchEvent(new CustomEvent('undo', { bubbles: true, composed: true }));
}
```
**Step 2: 在执行区渲染按钮**
在 `renderExecutionActions()` 的非 executing 分支中加入:
```ts
<button
class="button button-default"
?disabled=${!this.canUndo || this.undoBusy}
@click=${this.handleUndo}
>
${this.undoBusy ? I18nService.t('undo_in_progress') : I18nService.t('undo_last_rename')}
</button>
```
并在 `file-selector-panel.ts` 的 `render()` 中传入:
```ts
.canUndo=${Boolean(this.lastRenameOperation)}
.undoBusy=${this.undoBusy}
@undo=${this.handleUndoLastRename}
```
**Step 3: 增加 i18n 文案**
至少新增:
- `undo_last_rename`
- `undo_in_progress`
- `undo_failed_prefix`
- `undo_failed_manual_hint`
- `undo_scope_hint`
**Step 4: 运行定向测试/构建检查**
Run:
```bash
npm run typecheck
npm run lint
```
Expected: PASS
**Step 5: Commit**
```bash
git add src/content/components/config-panel.ts src/locales/zh_CN.json src/locales/en.json src/locales/zh_TW.json
git commit -m "feat: add undo action to rename panel"
```
### Task 5: 完成失败提示与整体验证
**Files:**
- Modify: `src/content/components/file-selector-panel.ts`
- Modify: `tests/integration/batch-execution.test.ts`(如需新增集成回归样例)
- Optional: `tests/e2e/batch-rename.spec.ts`
**Step 1: 在 undo 失败分支拼接提示文本**
示例:
```ts
const failedLines = results.failed.map(item => `- ${item.file.name}: ${item.error}`);
alert([
I18nService.t('undo_failed_prefix'),
...failedLines,
'',
I18nService.t('undo_failed_manual_hint'),
].join('\n'));
```
**Step 2: 增加一条集成回归样例(推荐)**
目标断言:
- 首次执行成功 + retry 成功后,唯一记录包含两批成功项
- undo 后两批文件都恢复原名
**Step 3: 运行完整验证**
Run:
```bash
npm run test:ci
npm run typecheck
npm run lint
```
Expected: 全部通过
**Step 4: 手工验证当前目录 scope**
Run:
```bash
npm run dev
```
手工检查:
1. 在同目录执行批量重命名;
2. 关闭面板,再打开,undo 按钮仍可用;
3. 切换目录,再打开面板,undo 按钮消失;
4. 回原目录(若页面未刷新且采用懒清理,则记录已被清空,这是符合当前规则的);
5. retry 成功的文件也会被 undo 回退。
**Step 5: Commit**
```bash
git add src/content/components/file-selector-panel.ts tests/integration/batch-execution.test.ts tests/e2e/batch-rename.spec.ts
git commit -m "test: verify last batch undo workflow"
```