feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
@@ -26,6 +26,7 @@ function validTask(overrides: Record<string, unknown> = {}): Record<string, unkn
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
completedAt: null,
|
||||
archivedAt: null,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -106,6 +107,18 @@ describe('coerceTask', () => {
|
||||
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('非法 deletedAt 收敛为 null', () => {
|
||||
expect(coerceTask(validTask({ deletedAt: 123 }))?.deletedAt).toBeNull()
|
||||
expect(coerceTask(validTask({ deletedAt: null }))?.deletedAt).toBeNull()
|
||||
expect(coerceTask(validTask({ deletedAt: undefined }))?.deletedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('保留合法的 deletedAt', () => {
|
||||
expect(coerceTask(validTask({ deletedAt: '2026-10-19T00:00:00.000Z' }))?.deletedAt).toBe(
|
||||
'2026-10-19T00:00:00.000Z',
|
||||
)
|
||||
})
|
||||
|
||||
it('保留合法的 archivedAt', () => {
|
||||
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
|
||||
'2026-10-01T00:00:00.000Z',
|
||||
@@ -226,6 +239,51 @@ describe('版本迁移', () => {
|
||||
const state = parsePersistedState(v1)
|
||||
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('v2 数据迁移到 v3 时补上 deletedAt,其余字段不变', () => {
|
||||
const v2 = JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
tasks: [
|
||||
{
|
||||
id: 'legacy',
|
||||
title: '归档任务',
|
||||
status: 'done',
|
||||
priority: 2,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
completedAt: '2026-01-02T00:00:00.000Z',
|
||||
archivedAt: '2026-01-03T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const state = parsePersistedState(v2)
|
||||
|
||||
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
||||
expect(state.tasks[0]?.deletedAt).toBeNull()
|
||||
expect(state.tasks[0]?.archivedAt).toBe('2026-01-03T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('v0/v1/v2 都能一路迁移到 v3', () => {
|
||||
const builds = [
|
||||
() => ({ tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
() => ({ schemaVersion: 1, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
() => ({ schemaVersion: 2, tasks: [{ id: 'a', title: 'A', status: 'todo', priority: 2 }] }),
|
||||
]
|
||||
|
||||
for (const build of builds) {
|
||||
const state = parsePersistedState(JSON.stringify(build()))
|
||||
expect(state.schemaVersion).toBe(3)
|
||||
expect(state.tasks[0]?.deletedAt).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('删除状态在序列化往返后保留', () => {
|
||||
const tasks = coerceTaskList([validTask({ deletedAt: '2026-10-19T00:00:00.000Z' })])
|
||||
const roundTrip = parsePersistedState(serializeState(tasks))
|
||||
expect(roundTrip.tasks[0]?.deletedAt).toBe('2026-10-19T00:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('序列化往返', () => {
|
||||
|
||||
Reference in New Issue
Block a user