feat: 回收站、批量编辑、合并导入与标签治理

- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理
- 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标
- 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档
- 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照
- 标签治理:全局重命名、合并、删除;回收站任务不参与改写
- 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉
- UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组
- 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
chenshoulin
2026-10-19 18:32:42 +08:00
parent 43e813b93a
commit 65e98199ba
39 changed files with 2734 additions and 143 deletions
+117
View File
@@ -0,0 +1,117 @@
import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import TrashPanel from './TrashPanel.vue'
function setup(prepare?: (store: ReturnType<typeof useTaskStore>) => void) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
prepare?.(store)
const wrapper = mount(TrashPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
return { store, wrapper }
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('TrashPanel 展示', () => {
beforeEach(() => setActivePinia(createPinia()))
it('回收站为空时显示空状态', () => {
const { wrapper } = setup()
expect(wrapper.text()).toContain('回收站是空的')
})
it('列出标题、删除时间与原列,并统计数量', () => {
const { wrapper } = setup((store) => {
const task = store.createTask({ title: '被删除的任务', status: 'doing' })
store.removeTask(task?.id ?? '')
})
expect(wrapper.text()).toContain('被删除的任务')
expect(wrapper.text()).toContain('原列 进行中')
expect(wrapper.text()).toContain('删除于')
expect(wrapper.text()).toContain('共 1 个任务')
})
it('已归档的删除项带「归档」徽标', () => {
const { wrapper } = setup((store) => {
const task = store.createTask({ title: '归档任务', status: 'done' })
store.archiveTasks([task?.id ?? ''])
store.removeTask(task?.id ?? '')
})
expect(wrapper.text()).toContain('归档')
})
})
describe('TrashPanel 操作', () => {
beforeEach(() => setActivePinia(createPinia()))
it('恢复后回到看板,回收站变空', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: '可恢复' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '恢复')?.trigger('click')
expect(store.activeTasks).toHaveLength(1)
expect(store.trashTasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('永久删除后数据真正消失', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: '永久删除' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '永久删除')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('清空回收站需要二次确认', async () => {
const { store, wrapper } = setup((store) => {
const a = store.createTask({ title: 'A' })
const b = store.createTask({ title: 'B' })
store.removeTasks([a?.id ?? '', b?.id ?? ''])
})
await buttonByText(wrapper, '清空回收站')?.trigger('click')
expect(wrapper.text()).toContain('此操作不可撤销')
expect(store.tasks).toHaveLength(2)
await buttonByText(wrapper, '确认清空')?.trigger('click')
expect(store.tasks).toHaveLength(0)
expect(wrapper.text()).toContain('回收站是空的')
})
it('取消清空不会删除数据', async () => {
const { store, wrapper } = setup((store) => {
const task = store.createTask({ title: 'A' })
store.removeTask(task?.id ?? '')
})
await buttonByText(wrapper, '清空回收站')?.trigger('click')
await buttonByText(wrapper, '取消')?.trigger('click')
expect(store.trashTasks).toHaveLength(1)
expect(wrapper.text()).not.toContain('此操作不可撤销')
})
})