From 65e98199ba80d4c0a8bb92bb3cdee790dd97e1e3 Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Mon, 19 Oct 2026 18:32:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=9E=E6=94=B6=E7=AB=99=E3=80=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E7=BC=96=E8=BE=91=E3=80=81=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E4=B8=8E=E6=A0=87=E7=AD=BE=E6=B2=BB=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例 --- src/App.vue | 4 + src/components/AppNav.vue | 37 +- src/components/BackupPanel.spec.ts | 104 ++++++ src/components/BackupPanel.vue | 83 ++++- src/components/BatchAddDrawer.spec.ts | 2 +- src/components/BatchSelectionBar.spec.ts | 69 ++++ src/components/BatchSelectionBar.vue | 265 +++++++++++--- src/components/BoardColumn.spec.ts | 1 + src/components/BoardToolbar.vue | 16 +- src/components/QuickAddTask.spec.ts | 2 +- src/components/TagFilter.vue | 16 +- src/components/TagManagerPanel.spec.ts | 120 +++++++ src/components/TagManagerPanel.vue | 182 ++++++++++ src/components/TaskCard.spec.ts | 1 + src/components/TaskDetailDrawer.spec.ts | 3 +- src/components/TrashPanel.spec.ts | 117 ++++++ src/components/TrashPanel.vue | 160 +++++++++ src/data/schema.spec.ts | 58 +++ src/data/schema.ts | 10 +- src/data/snapshot.spec.ts | 1 + src/data/taskRepository.spec.ts | 1 + src/stores/tasks.spec.ts | 437 ++++++++++++++++++++++- src/stores/tasks.ts | 357 ++++++++++++++---- src/stores/ui.ts | 34 +- src/types/task.ts | 7 + src/utils/archive.spec.ts | 1 + src/utils/filter.spec.ts | 1 + src/utils/merge.spec.ts | 105 ++++++ src/utils/merge.ts | 75 ++++ src/utils/sortOrder.spec.ts | 1 + src/utils/stats.spec.ts | 1 + src/utils/tags.spec.ts | 120 +++++++ src/utils/tags.ts | 117 ++++++ src/utils/taskPatch.spec.ts | 107 ++++++ src/utils/taskPatch.ts | 85 +++++ src/utils/today.spec.ts | 1 + src/views/BoardView.spec.ts | 77 +++- src/views/BoardView.vue | 85 ++++- src/views/TodayView.vue | 14 +- 39 files changed, 2734 insertions(+), 143 deletions(-) create mode 100644 src/components/TagManagerPanel.spec.ts create mode 100644 src/components/TagManagerPanel.vue create mode 100644 src/components/TrashPanel.spec.ts create mode 100644 src/components/TrashPanel.vue create mode 100644 src/utils/merge.spec.ts create mode 100644 src/utils/merge.ts create mode 100644 src/utils/tags.spec.ts create mode 100644 src/utils/tags.ts create mode 100644 src/utils/taskPatch.spec.ts create mode 100644 src/utils/taskPatch.ts diff --git a/src/App.vue b/src/App.vue index de95107..02db36b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,7 +7,9 @@ import AppNav from '@/components/AppNav.vue' import BackupPanel from '@/components/BackupPanel.vue' import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue' import SideDrawer from '@/components/SideDrawer.vue' +import TagManagerPanel from '@/components/TagManagerPanel.vue' import ToastHost from '@/components/ToastHost.vue' +import TrashPanel from '@/components/TrashPanel.vue' import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts' import { useTaskStore } from '@/stores/tasks' import { useThemeStore } from '@/stores/theme' @@ -68,6 +70,8 @@ useKeyboardShortcuts({ + +

diff --git a/src/components/AppNav.vue b/src/components/AppNav.vue index 8bc9257..62034de 100644 --- a/src/components/AppNav.vue +++ b/src/components/AppNav.vue @@ -1,11 +1,13 @@ diff --git a/src/components/TagManagerPanel.spec.ts b/src/components/TagManagerPanel.spec.ts new file mode 100644 index 0000000..cfbb96d --- /dev/null +++ b/src/components/TagManagerPanel.spec.ts @@ -0,0 +1,120 @@ +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 TagManagerPanel from './TagManagerPanel.vue' + +function setup(prepare?: (store: ReturnType) => void) { + const pinia = createPinia() + setActivePinia(pinia) + const store = useTaskStore() + store.init({ repository: createMemoryTaskRepository(), crossTab: false }) + prepare?.(store) + + const wrapper = mount(TagManagerPanel, { + 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() === text) +} + +describe('TagManagerPanel 展示', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('没有标签时显示空状态', () => { + const { wrapper } = setup() + expect(wrapper.text()).toContain('还没有任何标签') + }) + + it('列出标签与使用数量', () => { + const { wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + store.createTask({ title: 'B', tags: ['前端'] }) + }) + + expect(wrapper.text()).toContain('#前端') + expect(wrapper.text()).toContain('2 个任务') + }) +}) + +describe('TagManagerPanel 重命名', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('重命名后所有任务同步', async () => { + const { store, wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + store.createTask({ title: 'B', tags: ['前端', '设计'] }) + }) + + const input = wrapper.find('input[aria-label="重命名标签 前端"]') + await input.setValue('Frontend') + await buttonByText(wrapper, '保存')?.trigger('click') + + expect([...store.allTags].sort()).toEqual(['Frontend', '设计'].sort()) + expect(wrapper.text()).toContain('#Frontend') + expect(wrapper.text()).not.toContain('#前端') + }) + + it('清空输入后保存不改动标签', async () => { + const { store, wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + }) + + await wrapper.find('input[aria-label="重命名标签 前端"]').setValue(' ') + await buttonByText(wrapper, '保存')?.trigger('click') + + expect(store.allTags).toEqual(['前端']) + }) +}) + +describe('TagManagerPanel 合并与删除', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('合并两个标签后去重', async () => { + const { store, wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端', '设计'] }) + store.createTask({ title: 'B', tags: ['设计'] }) + }) + + await wrapper.find('select[aria-label="把标签 前端 合并到"]').setValue('设计') + await buttonByText(wrapper, '合并')?.trigger('click') + + expect(store.allTags).toEqual(['设计']) + expect(store.activeTasks.every((task) => !task.tags.includes('前端'))).toBe(true) + }) + + it('删除标签需要二次确认,且不删除任务', async () => { + const { store, wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + }) + + await buttonByText(wrapper, '删除')?.trigger('click') + expect(store.allTags).toEqual(['前端']) + + await buttonByText(wrapper, '确认删除')?.trigger('click') + + expect(store.allTags).toEqual([]) + expect(store.activeTasks).toHaveLength(1) + }) + + it('取消删除保留标签', async () => { + const { store, wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + }) + + await buttonByText(wrapper, '删除')?.trigger('click') + await buttonByText(wrapper, '取消')?.trigger('click') + + expect(store.allTags).toEqual(['前端']) + expect(wrapper.text()).not.toContain('确认删除') + }) +}) diff --git a/src/components/TagManagerPanel.vue b/src/components/TagManagerPanel.vue new file mode 100644 index 0000000..76afaa5 --- /dev/null +++ b/src/components/TagManagerPanel.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/components/TaskCard.spec.ts b/src/components/TaskCard.spec.ts index 63a852d..9aacc97 100644 --- a/src/components/TaskCard.spec.ts +++ b/src/components/TaskCard.spec.ts @@ -21,6 +21,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/components/TaskDetailDrawer.spec.ts b/src/components/TaskDetailDrawer.spec.ts index a7c449e..66fae01 100644 --- a/src/components/TaskDetailDrawer.spec.ts +++ b/src/components/TaskDetailDrawer.spec.ts @@ -231,7 +231,8 @@ describe('TaskDetailDrawer 删除与撤销', () => { await buttonByText(wrapper, '删除')?.trigger('click') - expect(store.tasks).toHaveLength(0) + expect(store.activeTasks).toHaveLength(0) + expect(store.trashTasks).toHaveLength(1) expect(wrapper.emitted('close')).toHaveLength(1) expect(toast.toasts).toHaveLength(1) expect(toast.toasts[0]?.action?.label).toBe('撤销') diff --git a/src/components/TrashPanel.spec.ts b/src/components/TrashPanel.spec.ts new file mode 100644 index 0000000..05123a0 --- /dev/null +++ b/src/components/TrashPanel.spec.ts @@ -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) => 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('此操作不可撤销') + }) +}) diff --git a/src/components/TrashPanel.vue b/src/components/TrashPanel.vue new file mode 100644 index 0000000..a1a5dff --- /dev/null +++ b/src/components/TrashPanel.vue @@ -0,0 +1,160 @@ + + + diff --git a/src/data/schema.spec.ts b/src/data/schema.spec.ts index a43a91e..9cc321d 100644 --- a/src/data/schema.spec.ts +++ b/src/data/schema.spec.ts @@ -26,6 +26,7 @@ function validTask(overrides: Record = {}): Record { 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('序列化往返', () => { diff --git a/src/data/schema.ts b/src/data/schema.ts index cfa9b0e..69da18a 100644 --- a/src/data/schema.ts +++ b/src/data/schema.ts @@ -9,7 +9,7 @@ import { isColumnId, isPriority, type Task } from '@/types/task' -export const SCHEMA_VERSION = 2 +export const SCHEMA_VERSION = 3 /** 主数据在 localStorage 中的键名 */ export const STORAGE_KEY = 'kanban.tasks' @@ -74,6 +74,8 @@ export function coerceTask(value: unknown): Task | null { const updatedAt = readString(value, 'updatedAt') ?? createdAt const completedAt = readString(value, 'completedAt') const archivedAt = readString(value, 'archivedAt') + // 与 archivedAt 一致:非字符串(含 undefined / 数字 / 对象)一律收敛为 null + const deletedAt = readString(value, 'deletedAt') return { id, @@ -88,6 +90,7 @@ export function coerceTask(value: unknown): Task | null { updatedAt, completedAt, archivedAt, + deletedAt, } } @@ -113,6 +116,7 @@ type Migration = (state: PersistedState) => PersistedState * 版本迁移表,键为「迁移前的版本号」。 * - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致 * - v1 -> v2:新增 archivedAt 字段(归档功能) + * - v2 -> v3:新增 deletedAt 字段(回收站 / 软删除) * 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。 */ const MIGRATIONS: Record = { @@ -121,6 +125,10 @@ const MIGRATIONS: Record = { schemaVersion: 2, tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })), }), + 2: (state) => ({ + schemaVersion: 3, + tasks: state.tasks.map((task) => ({ ...task, deletedAt: null })), + }), } /** diff --git a/src/data/snapshot.spec.ts b/src/data/snapshot.spec.ts index 15641a2..a09e47d 100644 --- a/src/data/snapshot.spec.ts +++ b/src/data/snapshot.spec.ts @@ -44,6 +44,7 @@ function makeTask(id: string): Task { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, } } diff --git a/src/data/taskRepository.spec.ts b/src/data/taskRepository.spec.ts index c81a8b1..4f30a2b 100644 --- a/src/data/taskRepository.spec.ts +++ b/src/data/taskRepository.spec.ts @@ -43,6 +43,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/stores/tasks.spec.ts b/src/stores/tasks.spec.ts index 96889db..0258081 100644 --- a/src/stores/tasks.spec.ts +++ b/src/stores/tasks.spec.ts @@ -37,6 +37,7 @@ describe('初始化', () => { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, }, ]) const store = freshStore(repository) @@ -251,7 +252,8 @@ describe('删除与撤销', () => { const store = freshStore() const task = store.createTask({ title: 'A' }) expect(store.removeTask(task?.id ?? '')?.title).toBe('A') - expect(store.tasks).toHaveLength(0) + expect(store.activeTasks).toHaveLength(0) + expect(store.trashTasks.map((item) => item.title)).toEqual(['A']) }) it('撤销后连原顺序一起恢复', () => { @@ -412,7 +414,8 @@ describe('持久化', () => { first.removeTask(task?.id ?? '') const second = freshStore(repository) - expect(second.tasks).toHaveLength(0) + expect(second.activeTasks).toHaveLength(0) + expect(second.trashTasks).toHaveLength(1) }) it('写入失败时标记为非持久化并提示,恢复后自动清除', () => { @@ -534,6 +537,7 @@ describe('导出与导入', () => { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, } vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z')) @@ -676,3 +680,432 @@ describe('规模与稳定性', () => { expect(store.tasks).toHaveLength(60) }) }) + +function makePersistedTask(overrides: Partial = {}): Task { + return { + id: 'persisted', + title: '持久化任务', + description: '', + status: 'todo', + priority: 2, + tags: [], + dueDate: null, + sortOrder: 1024, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: '2026-09-01T00:00:00.000Z', + completedAt: null, + archivedAt: null, + deletedAt: null, + ...overrides, + } +} + +describe('回收站', () => { + it('软删除后从看板消失,但保留在回收站', () => { + const store = freshStore() + const a = store.createTask({ title: 'A' }) + store.createTask({ title: 'B' }) + + store.removeTask(a?.id ?? '') + + expect(titles(store.byColumn.todo)).toEqual(['B']) + expect(store.counts.todo).toBe(1) + expect(store.trashTasks.map((task) => task.title)).toEqual(['A']) + expect(store.trashTasks[0]?.deletedAt).not.toBeNull() + // 数据本身仍在,导出可带走 + expect(store.tasks).toHaveLength(2) + expect(store.exportJson()).toContain('"deletedAt"') + }) + + it('恢复后回到原列原序,deletedAt 清空', () => { + const store = freshStore() + store.createTask({ title: 'A' }) + const middle = store.createTask({ title: 'B' }) + store.createTask({ title: 'C' }) + expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A']) + + const removed = store.removeTask(middle?.id ?? '') + store.restoreTask(removed!) + + expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A']) + expect(store.tasks.find((task) => task.id === middle?.id)?.deletedAt).toBeNull() + expect(store.trashTasks).toHaveLength(0) + }) + + it('删除已归档任务后,恢复仍保留归档态', () => { + const store = freshStore() + const task = store.createTask({ title: '归档过的', status: 'done' }) + store.archiveTasks([task?.id ?? '']) + + const removed = store.removeTask(task?.id ?? '') + expect(removed?.archivedAt).not.toBeNull() + + store.restoreTask(removed!) + const restored = store.tasks.find((item) => item.id === task?.id) + expect(restored?.archivedAt).not.toBeNull() + expect(restored?.deletedAt).toBeNull() + }) + + it('回收站按删除时间倒序', () => { + vi.useFakeTimers() + try { + const store = freshStore() + vi.setSystemTime(new Date('2026-10-19T00:00:00.000Z')) + const first = store.createTask({ title: '先删的' }) + store.removeTask(first?.id ?? '') + + vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z')) + const second = store.createTask({ title: '后删的' }) + store.removeTask(second?.id ?? '') + + expect(store.trashTasks.map((task) => task.title)).toEqual(['后删的', '先删的']) + } finally { + vi.useRealTimers() + } + }) + + it('永久删除会真正移除,导出里也不再有', () => { + const store = freshStore() + const task = store.createTask({ title: '要永久删除' }) + const id = task?.id ?? '' + store.removeTask(id) + + expect(store.purgeTask(id)).toBe(true) + expect(store.tasks).toHaveLength(0) + expect(store.exportJson()).not.toContain('要永久删除') + }) + + it('只能永久删除回收站里的任务,未删除的不受影响', () => { + const store = freshStore() + const task = store.createTask({ title: '还在用' }) + + expect(store.purgeTask(task?.id ?? '')).toBe(false) + expect(store.purgeTasks([task?.id ?? ''])).toBe(0) + expect(store.tasks).toHaveLength(1) + }) + + it('批量永久删除返回实际数量', () => { + const store = freshStore() + const a = store.createTask({ title: 'A' }) + const b = store.createTask({ title: 'B' }) + store.removeTasks([a?.id ?? '', b?.id ?? '']) + + expect(store.purgeTasks([a?.id ?? '', b?.id ?? '', 'missing'])).toBe(2) + expect(store.tasks).toHaveLength(0) + }) + + it('清空回收站只删已删除的任务', () => { + const store = freshStore() + const kept = store.createTask({ title: '保留' }) + const doomed = store.createTask({ title: '删除' }) + store.removeTask(doomed?.id ?? '') + + expect(store.emptyTrash()).toBe(1) + expect(store.activeTasks.map((task) => task.id)).toEqual([kept?.id]) + }) + + it('31 天前的软删除项在下次启动时被自动清理', () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-10-19T00:00:00.000Z')) + const old = makePersistedTask({ + id: 'old', + deletedAt: '2026-09-01T00:00:00.000Z', + }) + const recent = makePersistedTask({ + id: 'recent', + deletedAt: '2026-10-18T00:00:00.000Z', + }) + + const store = freshStore(createMemoryTaskRepository([old, recent])) + + expect(store.tasks.map((task) => task.id)).toEqual(['recent']) + } finally { + vi.useRealTimers() + } + }) + + it('未超过保留期的任务不会被清理', () => { + const store = freshStore( + createMemoryTaskRepository([ + makePersistedTask({ id: 'recent', deletedAt: '2026-10-18T00:00:00.000Z' }), + ]), + ) + + expect(store.purgeExpiredTrash(30, new Date('2026-10-19T00:00:00.000Z'))).toBe(0) + expect(store.trashTasks).toHaveLength(1) + }) + + it('已删除任务不影响统计、今日分组与标签下拉', () => { + const store = freshStore() + const active = store.createTask({ title: '活跃', tags: ['共用'] }) + const archived = store.createTask({ title: '归档', tags: ['归档标签'] }) + const deleted = store.createTask({ title: '删除', tags: ['删除标签'] }) + + store.archiveTasks([archived?.id ?? '']) + store.removeTask(deleted?.id ?? '') + + expect(store.allTags).toEqual(['共用']) + // 归档任务仍属于看板数据(只是默认隐藏),已删除的必须彻底消失 + const todoIds = store.byColumn.todo.map((task) => task.id) + expect(todoIds).toEqual(expect.arrayContaining([active?.id, archived?.id])) + expect(todoIds).not.toContain(deleted?.id) + expect(store.counts.todo).toBe(2) + }) + + it('批量删除后撤销只恢复被删的那些', () => { + const store = freshStore() + const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) + const removed = store.removeTasks([created[0]?.id ?? '', created[2]?.id ?? '']) + + expect(store.byColumn.todo.map((task) => task.title)).toEqual(['B']) + store.restoreTasks(removed) + expect(titles(store.byColumn.todo)).toEqual(['A', 'B', 'C']) + }) +}) + +describe('批量编辑', () => { + it('批量设置优先级且只落盘一次', () => { + const repository = createMemoryTaskRepository() + const save = vi.spyOn(repository, 'save') + const store = freshStore(repository) + const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }]) + save.mockClear() + + expect(store.updateTasks(created.map((task) => task.id), { priority: 3 })).toBe(3) + expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true) + expect(save).toHaveBeenCalledTimes(1) + }) + + it('批量移动到目标列末尾并清空 completedAt', () => { + const store = freshStore() + store.createTask({ title: '已在做', status: 'doing' }) + const done1 = store.createTask({ title: 'D1', status: 'done' }) + const done2 = store.createTask({ title: 'D2', status: 'done' }) + + expect(store.updateTasks([done1?.id ?? '', done2?.id ?? ''], { status: 'doing' })).toBe(2) + + // 目标列末尾追加,且同批多条的相对顺序沿用它们在原列里的视觉顺序 + expect(store.byColumn.doing.map((task) => task.title)).toEqual(['已在做', 'D2', 'D1']) + for (const task of store.byColumn.doing) expect(task.completedAt).toBeNull() + }) + + it('批量移入已完成会写入 completedAt', () => { + const store = freshStore() + const a = store.createTask({ title: 'A' }) + store.updateTasks([a?.id ?? ''], { status: 'done' }) + expect(store.byColumn.done[0]?.completedAt).not.toBeNull() + }) + + it('批量加标签不影响原有标签', () => { + const store = freshStore() + const a = store.createTask({ title: 'A', tags: ['x', 'y'] }) + const b = store.createTask({ title: 'B', tags: ['y'] }) + + store.updateTasks([a?.id ?? '', b?.id ?? ''], { addTags: ['z'] }) + + expect(store.tasks.find((task) => task.id === a?.id)?.tags).toEqual(['x', 'y', 'z']) + expect(store.tasks.find((task) => task.id === b?.id)?.tags).toEqual(['y', 'z']) + }) + + it('批量移标签只移除指定标签', () => { + const store = freshStore() + const a = store.createTask({ title: 'A', tags: ['x', 'y'] }) + const b = store.createTask({ title: 'B', tags: ['x', 'z'] }) + + store.updateTasks([a?.id ?? '', b?.id ?? ''], { removeTags: ['x'] }) + + expect(store.tasks.find((task) => task.id === a?.id)?.tags).toEqual(['y']) + expect(store.tasks.find((task) => task.id === b?.id)?.tags).toEqual(['z']) + }) + + it('批量设置与清除截止日期', () => { + const store = freshStore() + const a = store.createTask({ title: 'A' }) + const b = store.createTask({ title: 'B' }) + const ids = [a?.id ?? '', b?.id ?? ''] + + store.updateTasks(ids, { dueDate: '2026-11-01' }) + expect(store.activeTasks.every((task) => task.dueDate === '2026-11-01')).toBe(true) + + store.updateTasks(ids, { dueDate: null }) + expect(store.activeTasks.every((task) => task.dueDate === null)).toBe(true) + }) + + it('批量归档只处理未删除任务', () => { + const store = freshStore() + const a = store.createTask({ title: 'A', status: 'done' }) + const deleted = store.createTask({ title: 'B' }) + store.removeTask(deleted?.id ?? '') + + expect(store.updateTasks([a?.id ?? '', deleted?.id ?? ''], { archived: true })).toBe(1) + expect(store.tasks.find((task) => task.id === a?.id)?.archivedAt).not.toBeNull() + expect(store.tasks.find((task) => task.id === deleted?.id)?.archivedAt).toBeNull() + }) + + it('空选择或空 patch 不写仓库', () => { + const repository = createMemoryTaskRepository() + const save = vi.spyOn(repository, 'save') + const store = freshStore(repository) + const task = store.createTask({ title: 'A' }) + save.mockClear() + + expect(store.updateTasks([], { priority: 1 })).toBe(0) + expect(store.updateTasks([task?.id ?? ''], {})).toBe(0) + expect(save).not.toHaveBeenCalled() + }) +}) + +describe('合并导入', () => { + function payload(tasks: Record[]): string { + return JSON.stringify({ schemaVersion: 3, tasks }) + } + + function payloadTask(overrides: Record): Record { + return { + id: 'incoming', + title: '导入任务', + status: 'todo', + priority: 2, + sortOrder: 1024, + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + ...overrides, + } + } + + it('同 id 新者胜、缺失的新增、本地独有保留', () => { + const store = freshStore() + const localA = store.createTask({ title: '本地 A' }) + store.createTask({ title: '本地独有' }) + const local = store.tasks.find((task) => task.id === localA?.id) + + const result = store.mergeJson( + payload([ + payloadTask({ + id: localA?.id, + title: '更新后的 A', + createdAt: local?.createdAt, + updatedAt: '2099-01-01T00:00:00.000Z', + }), + payloadTask({ id: 'new-b', title: '文件新增' }), + ]), + ) + + expect(result).toEqual({ added: 1, updated: 1, skipped: 0 }) + expect(store.activeTasks).toHaveLength(3) + expect(titles(store.activeTasks).sort()).toEqual(['更新后的 A', '文件新增', '本地独有'].sort()) + + const added = store.activeTasks.find((task) => task.id === 'new-b') + const others = store.activeTasks.filter((task) => task.status === 'todo' && task.id !== 'new-b') + expect(added?.sortOrder).toBeGreaterThan(Math.max(...others.map((task) => task.sortOrder))) + }) + + it('同 id 且 updatedAt 相同则跳过', () => { + const store = freshStore() + const local = store.createTask({ title: '本地' }) + const current = store.tasks.find((task) => task.id === local?.id) + + const result = store.mergeJson( + payload([ + payloadTask({ + id: local?.id, + title: '文件', + createdAt: current?.createdAt, + updatedAt: current?.updatedAt, + }), + ]), + ) + + expect(result).toEqual({ added: 0, updated: 0, skipped: 1 }) + expect(store.activeTasks[0]?.title).toBe('本地') + }) + + it('合并不删除本地独有任务,也不产生重复 id', () => { + const store = freshStore() + store.createTasks([{ title: 'A' }, { title: 'B' }]) + store.mergeJson(payload([payloadTask({ id: 'c', title: 'C' })])) + + const ids = store.activeTasks.map((task) => task.id) + expect(new Set(ids).size).toBe(ids.length) + expect(store.activeTasks).toHaveLength(3) + }) + + it('合并前自动写入快照', () => { + const store = freshStore() + store.createTask({ title: '合并前' }) + expect(store.snapshots).toHaveLength(0) + + store.mergeJson(payload([payloadTask({ id: 'x', title: 'X' })])) + + expect(store.snapshots).toHaveLength(1) + }) + + it('非法文件不改变现有数据,也不写快照', () => { + const store = freshStore() + store.createTask({ title: '保留' }) + + expect(() => store.mergeJson('{"tasks":[{"id":"broken"}]}')).toThrow() + expect(store.activeTasks).toHaveLength(1) + expect(store.snapshots).toHaveLength(0) + }) + + it('合并后重建 store 结果不变', () => { + const repository = createMemoryTaskRepository() + const first = freshStore(repository) + first.createTask({ title: '本地' }) + first.mergeJson(payload([payloadTask({ id: 'new', title: '新增' })])) + + const second = freshStore(repository) + expect(titles(second.activeTasks).sort()).toEqual(['新增', '本地'].sort()) + }) +}) + +describe('标签治理', () => { + it('重命名同步全部任务并返回受影响数量', () => { + const store = freshStore() + store.createTask({ title: 'A', tags: ['前端', '设计'] }) + store.createTask({ title: 'B', tags: ['前端'] }) + store.createTask({ title: 'C', tags: ['后端'] }) + + expect(store.renameTag('前端', 'Frontend')).toBe(2) + expect(store.allTags).toEqual(['Frontend', '后端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))) + expect(store.tasks.find((task) => task.title === 'A')?.tags).toEqual(['Frontend', '设计']) + }) + + it('合并标签后去重且计数正确', () => { + const store = freshStore() + store.createTask({ title: 'A', tags: ['前端', 'Frontend'] }) + store.createTask({ title: 'B', tags: ['Frontend'] }) + + expect(store.mergeTags(['前端'], 'Frontend')).toBe(1) + expect(store.allTags).toEqual(['Frontend']) + expect(store.tasks.find((task) => task.title === 'A')?.tags).toEqual(['Frontend']) + }) + + it('删除标签不删除任务', () => { + const store = freshStore() + store.createTask({ title: 'A', tags: ['前端'] }) + store.createTask({ title: 'B', tags: ['前端', '设计'] }) + + expect(store.removeTag('前端')).toBe(2) + expect(store.activeTasks).toHaveLength(2) + expect(store.allTags).toEqual(['设计']) + }) + + it('重命名不存在的标签不改动任何任务', () => { + const store = freshStore() + store.createTask({ title: 'A', tags: ['前端'] }) + expect(store.renameTag('不存在', 'X')).toBe(0) + expect(store.allTags).toEqual(['前端']) + }) + + it('标签操作不修改回收站里的任务', () => { + const store = freshStore() + const task = store.createTask({ title: 'A', tags: ['前端'] }) + store.removeTask(task?.id ?? '') + + expect(store.renameTag('前端', 'Frontend')).toBe(0) + expect(store.trashTasks[0]?.tags).toEqual(['前端']) + }) +}) diff --git a/src/stores/tasks.ts b/src/stores/tasks.ts index 5b59fa1..f396e20 100644 --- a/src/stores/tasks.ts +++ b/src/stores/tasks.ts @@ -17,14 +17,28 @@ import { type SnapshotInfo, type TaskRepository, } from '@/data/taskRepository' -import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task' +import { COLUMN_IDS, DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task' import { createId } from '@/utils/id' +import { mergeTasks as mergeTaskLists } from '@/utils/merge' import { groupByColumn, moveWithinColumn, sortByOrder, sortOrderForPosition, } from '@/utils/sortOrder' +import { + mergeTags as mergeTagsPure, + normalizeTags, + removeTag as removeTagPure, + renameTag as renameTagPure, +} from '@/utils/tags' +import { + applyTaskPatch, + isBatchPatchEmpty, + tagsAfterBatchPatch, + type BatchPatch, + type TaskPatch, +} from '@/utils/taskPatch' /** 跨标签页同步使用的频道名 */ export const SYNC_CHANNEL_NAME = 'kanban.sync' @@ -38,7 +52,7 @@ export interface CreateTaskInput { status?: ColumnId } -export type TaskPatch = Partial> +export type { BatchPatch, TaskPatch } from '@/utils/taskPatch' export interface InitOptions { /** 注入仓库;不传则使用 localStorage,不可用时降级为内存仓库 */ @@ -59,18 +73,6 @@ function isSyncMessage(value: unknown): value is SyncMessage { ) } -function normalizeTags(tags: readonly string[]): string[] { - const seen = new Set() - const result: string[] = [] - for (const tag of tags) { - const value = tag.trim() - if (!value || seen.has(value)) continue - seen.add(value) - result.push(value) - } - return result -} - export const useTaskStore = defineStore('tasks', () => { const tasks = ref([]) const ready = ref(false) @@ -84,7 +86,22 @@ export const useTaskStore = defineStore('tasks', () => { let repository: TaskRepository | null = null let channel: BroadcastChannel | null = null - const byColumn = computed(() => groupByColumn(tasks.value)) + /** + * 未删除的任务。所有「看板语义」的读取都必须走它: + * 列、计数、标签、统计、今日视图、归档候选…… + * 回收站不是筛选维度,而是独立的数据状态,因此在数据层剔除, + * 不交给 matchesFilter 处理。 + */ + const activeTasks = computed(() => tasks.value.filter((task) => task.deletedAt === null)) + + /** 回收站:已软删除的任务,按删除时间倒序(最近删的在最上面) */ + const trashTasks = computed(() => + tasks.value + .filter((task) => task.deletedAt !== null) + .sort((a, b) => (b.deletedAt ?? '').localeCompare(a.deletedAt ?? '')), + ) + + const byColumn = computed(() => groupByColumn(activeTasks.value)) const counts = computed(() => { const result = {} as Record @@ -96,11 +113,13 @@ export const useTaskStore = defineStore('tasks', () => { /** * 现有任务里出现过的全部标签,按中文排序。 - * 筛选下拉和编辑任务时的标签候选都用它,避免两处各算一份、结果不一致。 + * 只统计未删除、未归档的任务:否则筛选下拉里会出现 + * 「只有回收站/归档任务才用过」的标签,选中后一条都筛不出来。 */ const allTags = computed(() => { const collected = new Set() - for (const task of tasks.value) { + for (const task of activeTasks.value) { + if (task.archivedAt !== null) continue for (const tag of task.tags) collected.add(tag) } return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')) @@ -238,6 +257,8 @@ export const useTaskStore = defineStore('tasks', () => { ready.value = true + // 先清理过期回收站再写启动快照,避免快照里又留下一份本该消失的数据 + purgeExpiredTrash() writeStartupSnapshot() if (options.crossTab !== false) setupChannel() @@ -260,7 +281,7 @@ export const useTaskStore = defineStore('tasks', () => { const now = new Date().toISOString() /** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */ - const working = groupByColumn(tasks.value) + const working = groupByColumn(activeTasks.value) const created: Task[] = [] for (let index = drafts.length - 1; index >= 0; index -= 1) { @@ -282,6 +303,7 @@ export const useTaskStore = defineStore('tasks', () => { updatedAt: now, completedAt: status === 'done' ? now : null, archivedAt: null, + deletedAt: null, } working[status] = [task, ...working[status]] @@ -299,41 +321,21 @@ export const useTaskStore = defineStore('tasks', () => { } function updateTask(id: string, patch: TaskPatch): Task | null { - const index = tasks.value.findIndex((task) => task.id === id) + const index = tasks.value.findIndex((task) => task.id === id && task.deletedAt === null) const current = tasks.value[index] if (!current) return null const now = new Date().toISOString() const nextStatus = patch.status ?? current.status - let completedAt = current.completedAt - if (patch.completedAt !== undefined) { - completedAt = patch.completedAt - } else if (nextStatus === 'done' && current.status !== 'done') { - completedAt = now - } else if (nextStatus !== 'done' && current.status === 'done') { - completedAt = null - } - // 状态变更且未显式指定顺序时,追加到目标列末尾 let sortOrder = patch.sortOrder ?? current.sortOrder if (nextStatus !== current.status && patch.sortOrder === undefined) { - const target = groupByColumn(tasks.value)[nextStatus].filter((task) => task.id !== id) + const target = byColumn.value[nextStatus].filter((task) => task.id !== id) sortOrder = sortOrderForPosition(target, target.length) } - const title = patch.title === undefined ? current.title : patch.title.trim() || current.title - - const updated: Task = { - ...current, - ...patch, - title, - status: nextStatus, - sortOrder, - tags: patch.tags ? normalizeTags(patch.tags) : current.tags, - completedAt, - updatedAt: now, - } + const updated = applyTaskPatch(current, { ...patch, sortOrder }, now) const next = [...tasks.value] next[index] = updated @@ -342,65 +344,227 @@ export const useTaskStore = defineStore('tasks', () => { return updated } - /** 删除任务,返回被删除的任务本身,供撤销使用(T12) */ - function removeTask(id: string): Task | null { - const task = tasks.value.find((item) => item.id === id) - if (!task) return null - tasks.value = tasks.value.filter((item) => item.id !== id) + /** + * 批量更新(一次 map + 一次 commit)。 + * + * 与单条编辑共用 applyTaskPatch,因此 completedAt、updatedAt、标签去重 + * 的状态流转规则只有一份。标签按「增 / 删」处理,避免批量加标签时 + * 把每条任务原有的其它标签抹掉。 + * + * @returns 实际更新的任务数 + */ + function updateTasks(ids: readonly string[], patch: BatchPatch): number { + const idSet = new Set(ids) + if (idSet.size === 0 || isBatchPatchEmpty(patch)) return 0 + + const now = new Date().toISOString() + /** 目标列工作副本:同批多条移入同一列时,按看板上的视觉顺序依次追加到末尾 */ + const columns = groupByColumn(activeTasks.value) + /** 按列顺序 + 列内 sortOrder 展开,避免按 tasks 数组的创建顺序处理 */ + const inBoardOrder = COLUMN_IDS.flatMap((columnId) => columns[columnId]) + const ordered = inBoardOrder.filter((task) => idSet.has(task.id)) + const updates = new Map() + let count = 0 + + for (const task of ordered) { + const taskPatch: TaskPatch = {} + if (patch.status !== undefined) taskPatch.status = patch.status + if (patch.priority !== undefined) taskPatch.priority = patch.priority + if (patch.dueDate !== undefined) taskPatch.dueDate = patch.dueDate + + if (patch.addTags !== undefined || patch.removeTags !== undefined) { + const tags = tagsAfterBatchPatch(task.tags, patch) + const unchanged = + tags.length === task.tags.length && tags.every((tag, i) => tag === task.tags[i]) + if (!unchanged) taskPatch.tags = tags + } + + if (patch.status !== undefined && patch.status !== task.status) { + const target = columns[patch.status].filter((item) => item.id !== task.id) + taskPatch.sortOrder = sortOrderForPosition(target, target.length) + } + + let updated = applyTaskPatch(task, taskPatch, now) + + if (patch.archived !== undefined) { + const archivedAt = patch.archived ? (task.archivedAt ?? now) : null + if (archivedAt !== updated.archivedAt) updated = { ...updated, archivedAt } + } + + if (updated.status === task.status) { + // 没有换列:原位替换,保持工作副本的列内顺序不变 + columns[task.status] = columns[task.status].map((item) => + item.id === updated.id ? updated : item, + ) + } else { + columns[task.status] = columns[task.status].filter((item) => item.id !== task.id) + columns[updated.status] = [ + ...columns[updated.status].filter((item) => item.id !== updated.id), + updated, + ] + } + + updates.set(updated.id, updated) + count += 1 + } + + if (count === 0) return 0 + + tasks.value = tasks.value.map((task) => updates.get(task.id) ?? task) commit() - return task + return count } - /** 撤销删除:连原 sortOrder 一起恢复,因此位置也会还原 */ + /** + * 删除任务 —— 软删除:写入 deletedAt,任务进入回收站。 + * 返回的仍是任务本身(带 deletedAt),供 Toast 撤销使用。 + */ + function removeTask(id: string): Task | null { + const task = tasks.value.find((item) => item.id === id && item.deletedAt === null) + if (!task) return null + + const now = new Date().toISOString() + const removed: Task = { ...task, deletedAt: now, updatedAt: now } + tasks.value = tasks.value.map((item) => (item.id === id ? removed : item)) + commit() + return removed + } + + /** 撤销删除 / 从回收站恢复:清除 deletedAt,原 sortOrder 与 archivedAt 一起保留 */ function restoreTask(task: Task): void { - if (tasks.value.some((item) => item.id === task.id)) return - tasks.value = [...tasks.value, task] + const existing = tasks.value.find((item) => item.id === task.id) + + if (!existing) { + tasks.value = [...tasks.value, { ...task, deletedAt: null }] + commit() + return + } + + if (existing.deletedAt === null) return + tasks.value = tasks.value.map((item) => + item.id === task.id ? { ...item, deletedAt: null } : item, + ) commit() } /** - * 批量删除,返回被删除的任务(保持原有顺序)。 + * 批量软删除,返回被删除的任务(保持原有顺序)。 * 供「批量添加」的撤销入口使用:一次操作删掉整批,而不是逐条点删除。 */ function removeTasks(ids: readonly string[]): Task[] { const idSet = new Set(ids) - const removed = tasks.value.filter((task) => idSet.has(task.id)) - if (removed.length === 0) return [] + const now = new Date().toISOString() + const removed: Task[] = [] - tasks.value = tasks.value.filter((task) => !idSet.has(task.id)) + tasks.value = tasks.value.map((task) => { + if (!idSet.has(task.id) || task.deletedAt !== null) return task + const next: Task = { ...task, deletedAt: now, updatedAt: now } + removed.push(next) + return next + }) + + if (removed.length === 0) return [] commit() return removed } /** 批量撤销删除:原 sortOrder 一起恢复,位置不变;已存在的不重复插入 */ function restoreTasks(items: readonly Task[]): void { - const existing = new Set(tasks.value.map((task) => task.id)) - const restored = items.filter((task) => !existing.has(task.id)) - if (restored.length === 0) return + const byId = new Map(tasks.value.map((task) => [task.id, task])) + let changed = false - tasks.value = [...tasks.value, ...restored] + for (const item of items) { + const existing = byId.get(item.id) + if (existing) { + if (existing.deletedAt === null) continue + byId.set(item.id, { ...existing, deletedAt: null }) + } else { + byId.set(item.id, { ...item, deletedAt: null }) + } + changed = true + } + + if (!changed) return + tasks.value = tasks.value.map((task) => byId.get(task.id) ?? task) + // 新插入的(已不在 tasks 里的)需要补进来 + const existingIds = new Set(tasks.value.map((task) => task.id)) + const appended = [...byId.values()].filter((task) => !existingIds.has(task.id)) + if (appended.length > 0) tasks.value = [...tasks.value, ...appended] commit() } + /** 永久删除单个任务(物理删除,不可撤销);只允许删除回收站里的任务 */ + function purgeTask(id: string): boolean { + const task = tasks.value.find((item) => item.id === id && item.deletedAt !== null) + if (!task) return false + + tasks.value = tasks.value.filter((item) => item.id !== id) + commit() + return true + } + + /** 永久删除多个任务;返回实际删除数量 */ + function purgeTasks(ids: readonly string[]): number { + const idSet = new Set(ids) + const remaining = tasks.value.filter( + (task) => !(idSet.has(task.id) && task.deletedAt !== null), + ) + const removed = tasks.value.length - remaining.length + if (removed === 0) return 0 + + tasks.value = remaining + commit() + return removed + } + + /** 永久删除 + 清空回收站 */ + function emptyTrash(): number { + return purgeTasks(trashTasks.value.map((task) => task.id)) + } + + /** + * 清理回收站中超过保留期的任务(默认 30 天)。 + * 删除时间无法解析时保留而非删除 —— 宁可多占一点配额, + * 也不因为一条脏数据把用户还可能需要的任务清掉。 + * + * @returns 实际清理的数量 + */ + function purgeExpiredTrash(days = 30, now: Date = new Date()): number { + const cutoff = now.getTime() - days * 24 * 60 * 60 * 1000 + const expiredIds = new Set() + + for (const task of tasks.value) { + if (task.deletedAt === null) continue + const deletedAt = new Date(task.deletedAt).getTime() + if (Number.isFinite(deletedAt) && deletedAt < cutoff) expiredIds.add(task.id) + } + + if (expiredIds.size === 0) return 0 + + tasks.value = tasks.value.filter((task) => !expiredIds.has(task.id)) + commit() + return expiredIds.size + } + /** * 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。 * status 与 sortOrder 在同一次赋值中更新,不存在中间态。 */ function moveTask(id: string, toStatus: ColumnId, toIndex: number): void { - const task = tasks.value.find((item) => item.id === id) + const task = tasks.value.find((item) => item.id === id && item.deletedAt === null) if (!task) return const now = new Date().toISOString() if (task.status === toStatus) { - const column = groupByColumn(tasks.value)[toStatus] + const column = byColumn.value[toStatus] const fromIndex = column.findIndex((item) => item.id === id) if (fromIndex === -1) return const moved = moveWithinColumn(column, fromIndex, toIndex) const byId = new Map(moved.map((item) => [item.id, item])) tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item) } else { - const target = groupByColumn(tasks.value)[toStatus].filter((item) => item.id !== id) + const target = byColumn.value[toStatus].filter((item) => item.id !== id) const sortOrder = sortOrderForPosition(target, toIndex) tasks.value = tasks.value.map((item) => item.id === id @@ -437,6 +601,20 @@ export const useTaskStore = defineStore('tasks', () => { return imported.length } + /** + * 合并式导入:同 id 以 updatedAt 较新的一方为准,其余新增到列尾。 + * 与覆盖导入一样,执行前先留一份快照。 + */ + function mergeJson(raw: string): { added: number; updated: number; skipped: number } { + const incoming = parseImportJson(raw) + snapshotBeforeOverwrite() + + const result = mergeTaskLists(tasks.value, incoming) + tasks.value = sortByOrder(result.tasks) + commit() + return { added: result.added, updated: result.updated, skipped: result.skipped } + } + /** 覆盖全部数据(供导入后的自定义处理或测试使用) */ function replaceAll(next: readonly Task[]): void { tasks.value = sortByOrder(next) @@ -453,7 +631,7 @@ export const useTaskStore = defineStore('tasks', () => { let count = 0 tasks.value = tasks.value.map((task) => { - if (!idSet.has(task.id) || task.archivedAt !== null) return task + if (!idSet.has(task.id) || task.deletedAt !== null || task.archivedAt !== null) return task count += 1 return { ...task, archivedAt: now, updatedAt: now } }) @@ -469,7 +647,7 @@ export const useTaskStore = defineStore('tasks', () => { let count = 0 tasks.value = tasks.value.map((task) => { - if (!idSet.has(task.id) || task.archivedAt === null) return task + if (!idSet.has(task.id) || task.deletedAt !== null || task.archivedAt === null) return task count += 1 return { ...task, archivedAt: null, updatedAt: now } }) @@ -478,6 +656,48 @@ export const useTaskStore = defineStore('tasks', () => { return count } + /** + * 标签治理:对未删除的任务应用一次纯函数变换,只刷新真正变化的那些任务。 + * 回收站里的任务不改 —— 它们不在任何常规视图里,改了也看不见, + * 反而会让「恢复后标签变了」显得莫名其妙。 + * + * @returns 受影响的任务数 + */ + function applyTagTransform(transform: (tasks: readonly Task[]) => Task[]): number { + const active = activeTasks.value + const transformed = transform(active) + const now = new Date().toISOString() + const changed = new Map() + let count = 0 + + transformed.forEach((task, index) => { + if (task === active[index]) return + count += 1 + changed.set(task.id, { ...task, updatedAt: now }) + }) + + if (count === 0) return 0 + + tasks.value = tasks.value.map((task) => changed.get(task.id) ?? task) + commit() + return count + } + + /** 重命名标签,返回受影响的任务数 */ + function renameTag(from: string, to: string): number { + return applyTagTransform((tasks) => renameTagPure(tasks, from, to)) + } + + /** 把多个标签合并为 into,返回受影响的任务数 */ + function mergeTags(from: readonly string[], into: string): number { + return applyTagTransform((tasks) => mergeTagsPure(tasks, from, into)) + } + + /** 从所有任务上删除某个标签(不删除任务),返回受影响的任务数 */ + function removeTag(tag: string): number { + return applyTagTransform((tasks) => removeTagPure(tasks, tag)) + } + /** 释放跨标签页频道;主要供测试使用 */ function dispose(): void { channel?.close() @@ -487,6 +707,8 @@ export const useTaskStore = defineStore('tasks', () => { return { tasks, + activeTasks, + trashTasks, ready, persistent, notice, @@ -498,16 +720,25 @@ export const useTaskStore = defineStore('tasks', () => { createTask, createTasks, updateTask, + updateTasks, removeTask, restoreTask, removeTasks, restoreTasks, + purgeTask, + purgeTasks, + emptyTrash, + purgeExpiredTrash, moveTask, exportJson, importJson, + mergeJson, replaceAll, archiveTasks, unarchiveTasks, + renameTag, + mergeTags, + removeTag, refreshSnapshots, restoreSnapshot, dispose, diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 75e25fb..0574b9e 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -1,9 +1,9 @@ /** * 应用级面板状态 * - * 备份面板与快捷键帮助都是**应用级**操作(在哪个视图都该能用), + * 备份、回收站、标签管理、快捷键帮助都是**应用级**操作(在哪个视图都该能用), * 因此状态放在这里,由 App.vue 统一渲染, - * 导航栏负责打开,看板负责在它们打开时让出快捷键。 + * 导航栏 / 工具栏负责打开,看板负责在它们打开时让出快捷键。 */ import { defineStore } from 'pinia' @@ -12,8 +12,12 @@ import { computed, ref } from 'vue' export const useUiStore = defineStore('ui', () => { const backupOpen = ref(false) const helpOpen = ref(false) + const trashOpen = ref(false) + const tagsOpen = ref(false) - const anyPanelOpen = computed(() => backupOpen.value || helpOpen.value) + const anyPanelOpen = computed( + () => backupOpen.value || helpOpen.value || trashOpen.value || tagsOpen.value, + ) function openBackup(): void { backupOpen.value = true @@ -31,19 +35,43 @@ export const useUiStore = defineStore('ui', () => { helpOpen.value = false } + function openTrash(): void { + trashOpen.value = true + } + + function closeTrash(): void { + trashOpen.value = false + } + + function openTags(): void { + tagsOpen.value = true + } + + function closeTags(): void { + tagsOpen.value = false + } + function closeAll(): void { backupOpen.value = false helpOpen.value = false + trashOpen.value = false + tagsOpen.value = false } return { backupOpen, helpOpen, + trashOpen, + tagsOpen, anyPanelOpen, openBackup, closeBackup, openHelp, closeHelp, + openTrash, + closeTrash, + openTags, + closeTags, closeAll, } }) diff --git a/src/types/task.ts b/src/types/task.ts index 960e174..848e57b 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -35,6 +35,13 @@ export interface Task { completedAt: string | null /** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */ archivedAt: string | null + /** + * 删除时间;null 表示未删除。 + * + * 删除是「软删除」:任务仍留在数据里,只是从看板、统计、标签等 + * 所有常规视图里剔除,直到用户在回收站里恢复或永久删除。 + */ + deletedAt: string | null } export interface ColumnDef { diff --git a/src/utils/archive.spec.ts b/src/utils/archive.spec.ts index 48dac6d..f425bb4 100644 --- a/src/utils/archive.spec.ts +++ b/src/utils/archive.spec.ts @@ -30,6 +30,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-01T00:00:00.000Z', completedAt: ago(10 * DAY_MS), archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/utils/filter.spec.ts b/src/utils/filter.spec.ts index d4a24c6..7d3a202 100644 --- a/src/utils/filter.spec.ts +++ b/src/utils/filter.spec.ts @@ -19,6 +19,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/utils/merge.spec.ts b/src/utils/merge.spec.ts new file mode 100644 index 0000000..8f7bd80 --- /dev/null +++ b/src/utils/merge.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' + +import type { Task } from '@/types/task' + +import { mergeTasks } from './merge' + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + title: '任务', + description: '', + status: 'todo', + priority: 2, + tags: [], + dueDate: null, + sortOrder: 1024, + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + completedAt: null, + archivedAt: null, + deletedAt: null, + ...overrides, + } +} + +describe('mergeTasks 冲突规则', () => { + it('同 id 以 updatedAt 较新的一方为准', () => { + const local = makeTask({ id: 'a', title: '旧标题' }) + const incoming = makeTask({ + id: 'a', + title: '新标题', + updatedAt: '2026-10-10T00:00:00.000Z', + }) + + const result = mergeTasks([local], [incoming]) + + expect(result.updated).toBe(1) + expect(result.added).toBe(0) + expect(result.tasks.find((task) => task.id === 'a')?.title).toBe('新标题') + }) + + it('本地更新时保留本地版本并计入 skipped', () => { + const local = makeTask({ id: 'a', title: '本地新', updatedAt: '2026-10-10T00:00:00.000Z' }) + const incoming = makeTask({ id: 'a', title: '文件旧' }) + + const result = mergeTasks([local], [incoming]) + + expect(result.updated).toBe(0) + expect(result.skipped).toBe(1) + expect(result.tasks[0]?.title).toBe('本地新') + }) + + it('updatedAt 相同视为同一条,跳过', () => { + const local = makeTask({ id: 'a', title: '本地' }) + const incoming = makeTask({ id: 'a', title: '文件' }) + + const result = mergeTasks([local], [incoming]) + + expect(result.skipped).toBe(1) + expect(result.tasks[0]?.title).toBe('本地') + }) + + it('文件新增的任务追加到对应列末尾', () => { + const local = makeTask({ id: 'a', sortOrder: 1024 }) + const incoming = makeTask({ id: 'b', title: '新增', sortOrder: 1 }) + + const result = mergeTasks([local], [incoming]) + const added = result.tasks.find((task) => task.id === 'b') + + expect(result.added).toBe(1) + expect(added?.sortOrder).toBeGreaterThan(local.sortOrder) + }) + + it('本地独有(文件里没有)的任务保留', () => { + const local = makeTask({ id: 'local-only', title: '本地保留' }) + const result = mergeTasks([local], []) + + expect(result.added).toBe(0) + expect(result.tasks.map((task) => task.title)).toEqual(['本地保留']) + }) + + it('归档与软删除状态跟随较新的一方', () => { + const local = makeTask({ id: 'a' }) + const incoming = makeTask({ + id: 'a', + archivedAt: '2026-10-10T00:00:00.000Z', + deletedAt: '2026-10-11T00:00:00.000Z', + updatedAt: '2026-10-10T00:00:00.000Z', + }) + + const result = mergeTasks([local], [incoming]) + const merged = result.tasks.find((task) => task.id === 'a') + + expect(merged?.archivedAt).toBe('2026-10-10T00:00:00.000Z') + expect(merged?.deletedAt).toBe('2026-10-11T00:00:00.000Z') + }) + + it('不修改入参数组', () => { + const local = [makeTask({ id: 'a' })] + const incoming = [makeTask({ id: 'b' })] + mergeTasks(local, incoming) + expect(local).toHaveLength(1) + expect(incoming).toHaveLength(1) + }) +}) diff --git a/src/utils/merge.ts b/src/utils/merge.ts new file mode 100644 index 0000000..7ff40ac --- /dev/null +++ b/src/utils/merge.ts @@ -0,0 +1,75 @@ +/** + * 合并导入 + * + * 覆盖导入适合「换设备后整体恢复」,但多设备、多次导出之后, + * 更常见的诉求是把两份数据拼起来。合并规则刻意做得简单可预期: + * + * - 按 id 匹配;同 id 比较 updatedAt,新者胜 + * - 文件里有、本地没有 → 新增,追加到对应列末尾 + * - 本地有、文件里没有 → 保留(合并 ≠ 同步,不删本地数据) + * - updatedAt 相同 → 视为同一条,跳过 + * + * 不做全局 sortOrder normalize:新增项只要各自落到列尾即可, + * 已经有冲突的旧数据不该被一次导入悄悄重排。 + */ + +import type { Task } from '@/types/task' +import { groupByColumn, sortOrderForPosition } from '@/utils/sortOrder' + +export interface MergeResult { + tasks: Task[] + /** 文件里新增的任务数 */ + added: number + /** 覆盖了本地较旧版本的任务数 */ + updated: number + /** 文件内同 id 但本地更新(或完全相同)而跳过的任务数 */ + skipped: number +} + +/** + * ISO 时间戳是定长 UTC 字符串,字典序比较与时间先后一致。 + * 用字符串比较而不是 Date.parse,避免非法时间被解析成 NaN 后静默胜出。 + */ +function isNewer(incoming: Task, current: Task): boolean { + return incoming.updatedAt > current.updatedAt +} + +export function mergeTasks(current: readonly Task[], incoming: readonly Task[]): MergeResult { + let tasks = [...current] + const byId = new Map(tasks.map((task) => [task.id, task])) + const added: Task[] = [] + let updated = 0 + let skipped = 0 + + for (const item of incoming) { + const existing = byId.get(item.id) + + if (!existing) { + added.push(item) + // 先登记,避免文件内重复 id(严格校验已拦截,这里只是兜底) + byId.set(item.id, item) + continue + } + + if (isNewer(item, existing)) { + tasks = tasks.map((task) => (task.id === item.id ? item : task)) + byId.set(item.id, item) + updated += 1 + } else { + skipped += 1 + } + } + + if (added.length > 0) { + const columns = groupByColumn(tasks) + for (const task of added) { + const column = columns[task.status] + const placed: Task = { ...task, sortOrder: sortOrderForPosition(column, column.length) } + columns[task.status] = [...column, placed] + tasks = [...tasks, placed] + byId.set(placed.id, placed) + } + } + + return { tasks, added: added.length, updated, skipped } +} diff --git a/src/utils/sortOrder.spec.ts b/src/utils/sortOrder.spec.ts index 2a57faf..381bd4e 100644 --- a/src/utils/sortOrder.spec.ts +++ b/src/utils/sortOrder.spec.ts @@ -31,6 +31,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-18T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/utils/stats.spec.ts b/src/utils/stats.spec.ts index 867bf7f..30457a0 100644 --- a/src/utils/stats.spec.ts +++ b/src/utils/stats.spec.ts @@ -27,6 +27,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-01T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/utils/tags.spec.ts b/src/utils/tags.spec.ts new file mode 100644 index 0000000..484bafb --- /dev/null +++ b/src/utils/tags.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' + +import type { Task } from '@/types/task' + +import { collectTagStats, mergeTags, normalizeTags, removeTag, renameTag } from './tags' + +function makeTask(id: string, tags: string[]): Task { + return { + id, + title: id, + description: '', + status: 'todo', + priority: 2, + tags, + dueDate: null, + sortOrder: 1024, + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + completedAt: null, + archivedAt: null, + deletedAt: null, + } +} + +describe('normalizeTags', () => { + it('去空白、丢弃空串并保持首次出现顺序', () => { + expect(normalizeTags([' 前端 ', '', '设计', '前端'])).toEqual(['前端', '设计']) + }) +}) + +describe('renameTag', () => { + it('同步改写所有任务并保持其它标签', () => { + const tasks = [makeTask('a', ['前端', '设计']), makeTask('b', ['前端'])] + const next = renameTag(tasks, '前端', 'Frontend') + + expect(next[0]?.tags).toEqual(['Frontend', '设计']) + expect(next[1]?.tags).toEqual(['Frontend']) + }) + + it('目标标签已存在时自动去重', () => { + const next = renameTag([makeTask('a', ['前端', 'Frontend'])], '前端', 'Frontend') + expect(next[0]?.tags).toEqual(['Frontend']) + }) + + it('目标为空或与原标签相同时不改动,且返回原对象', () => { + const task = makeTask('a', ['前端']) + expect(renameTag([task], '前端', ' ')[0]).toBe(task) + expect(renameTag([task], '前端', '前端')[0]).toBe(task) + }) + + it('不影响没有该标签的任务', () => { + const task = makeTask('a', ['设计']) + expect(renameTag([task], '前端', 'Frontend')[0]).toBe(task) + }) +}) + +describe('mergeTags', () => { + it('把多个标签合并为一个并去重', () => { + const next = mergeTags( + [makeTask('a', ['前端', 'Frontend', '设计'])], + ['前端', 'Frontend'], + 'FE', + ) + expect(next[0]?.tags).toEqual(['FE', '设计']) + }) + + it('目标是源标签之一时也能正确去重', () => { + const next = mergeTags([makeTask('a', ['前端', '设计'])], ['前端', '设计'], '设计') + expect(next[0]?.tags).toEqual(['设计']) + }) + + it('来源为空时不改动', () => { + const task = makeTask('a', ['前端']) + expect(mergeTags([task], [], 'X')[0]).toBe(task) + }) +}) + +describe('removeTag', () => { + it('只移除标签,不删除任务', () => { + const tasks = [makeTask('a', ['前端', '设计']), makeTask('b', ['前端'])] + const next = removeTag(tasks, '前端') + + expect(next).toHaveLength(2) + expect(next[0]?.tags).toEqual(['设计']) + expect(next[1]?.tags).toEqual([]) + }) + + it('空标签名不产生改动', () => { + const task = makeTask('a', ['前端']) + expect(removeTag([task], ' ')[0]).toBe(task) + }) +}) + +describe('collectTagStats', () => { + it('统计每个标签的使用次数', () => { + const stats = collectTagStats([ + makeTask('a', ['设计', '前端']), + makeTask('b', ['前端']), + makeTask('c', ['Bug']), + ]) + + expect(Object.fromEntries(stats.map((item) => [item.tag, item.count]))).toEqual({ + Bug: 1, + 前端: 2, + 设计: 1, + }) + }) + + it('按中文排序', () => { + const tags = ['设计', 'Bug', '前端'] + const stats = collectTagStats(tags.map((tag, index) => makeTask(`t${index}`, [tag]))) + const expected = [...tags].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')) + + expect(stats.map((item) => item.tag)).toEqual(expected) + }) + + it('空列表返回空数组', () => { + expect(collectTagStats([])).toEqual([]) + }) +}) diff --git a/src/utils/tags.ts b/src/utils/tags.ts new file mode 100644 index 0000000..c778926 --- /dev/null +++ b/src/utils/tags.ts @@ -0,0 +1,117 @@ +/** + * 标签治理纯函数 + * + * 标签是任务上的字符串数组,没有独立的标签表。重命名 / 合并 / 删除 + * 本质上都是「批量改写任务上的数组」,因此统一放在这里,store 只负责落盘。 + * + * 约定: + * - 全部 trim,空串丢弃,去重 + * - 保持原有标签顺序:被替换的位置就地替换,新标签追加到末尾 + * - 无需改动时返回**原任务对象**(引用不变),调用方据此统计影响数量 + */ + +import type { Task } from '@/types/task' + +/** 去空白、去重,保持首次出现的顺序 */ +export function normalizeTags(tags: readonly string[]): string[] { + const seen = new Set() + const result: string[] = [] + for (const tag of tags) { + const value = tag.trim() + if (!value || seen.has(value)) continue + seen.add(value) + result.push(value) + } + return result +} + +/** 在已有标签上追加一些、移除一些,返回规范化后的新数组 */ +export function applyTagChanges( + current: readonly string[], + add: readonly string[], + remove: readonly string[], +): string[] { + const removed = new Set(remove.map((tag) => tag.trim()).filter((tag) => tag !== '')) + return normalizeTags([...current.filter((tag) => !removed.has(tag)), ...add]) +} + +function sameTags(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((tag, index) => tag === right[index]) +} + +/** 用新的标签数组生成任务;内容不变时返回原对象 */ +function withTags(task: Task, tags: readonly string[]): Task { + const next = normalizeTags(tags) + return sameTags(task.tags, next) ? task : { ...task, tags: next } +} + +/** + * 把标签 from 重命名为 to。 + * to 为空或与 from 相同(去空白后)时不改动任何任务。 + */ +export function renameTag(tasks: readonly Task[], from: string, to: string): Task[] { + const source = from.trim() + const target = to.trim() + if (!source || !target || source === target) return [...tasks] + + return tasks.map((task) => + task.tags.includes(source) + ? withTags( + task, + task.tags.map((tag) => (tag === source ? target : tag)), + ) + : task, + ) +} + +/** + * 把多个标签合并为 into。 + * into 自身也在 from 里时会自然去重;from 为空时不做改动。 + */ +export function mergeTags(tasks: readonly Task[], from: readonly string[], into: string): Task[] { + const target = into.trim() + const sources = new Set(from.map((tag) => tag.trim()).filter((tag) => tag !== '')) + if (!target || sources.size === 0) return [...tasks] + + return tasks.map((task) => { + if (!task.tags.some((tag) => sources.has(tag))) return task + return withTags( + task, + task.tags.map((tag) => (sources.has(tag) ? target : tag)), + ) + }) +} + +/** 从所有任务上移除某个标签(不删除任务本身) */ +export function removeTag(tasks: readonly Task[], tag: string): Task[] { + const target = tag.trim() + if (!target) return [...tasks] + + return tasks.map((task) => + task.tags.includes(target) + ? withTags( + task, + task.tags.filter((item) => item !== target), + ) + : task, + ) +} + +export interface TagStat { + tag: string + count: number +} + +/** 统计每个标签被多少任务使用,按中文排序 */ +export function collectTagStats(tasks: readonly Task[]): TagStat[] { + const counts = new Map() + for (const task of tasks) { + for (const tag of task.tags) { + counts.set(tag, (counts.get(tag) ?? 0) + 1) + } + } + + return [...counts.entries()] + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => a.tag.localeCompare(b.tag, 'zh-Hans-CN')) +} diff --git a/src/utils/taskPatch.spec.ts b/src/utils/taskPatch.spec.ts new file mode 100644 index 0000000..c87852e --- /dev/null +++ b/src/utils/taskPatch.spec.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' + +import type { Task } from '@/types/task' + +import { applyTaskPatch, isBatchPatchEmpty, tagsAfterBatchPatch } from './taskPatch' + +const NOW = '2026-10-19T10:00:00.000Z' + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + title: '写方案', + description: '', + status: 'todo', + priority: 2, + tags: ['设计'], + dueDate: null, + sortOrder: 1024, + createdAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + completedAt: null, + archivedAt: null, + deletedAt: null, + ...overrides, + } +} + +describe('applyTaskPatch 状态流转', () => { + it('移入已完成时写入 completedAt', () => { + const next = applyTaskPatch(makeTask(), { status: 'done' }, NOW) + expect(next.status).toBe('done') + expect(next.completedAt).toBe(NOW) + }) + + it('移出已完成时清空 completedAt', () => { + const next = applyTaskPatch( + makeTask({ status: 'done', completedAt: '2026-10-02T00:00:00.000Z' }), + { status: 'todo' }, + NOW, + ) + expect(next.completedAt).toBeNull() + }) + + it('显式传入 completedAt 时以它为准', () => { + const explicit = '2026-09-01T00:00:00.000Z' + const next = applyTaskPatch(makeTask(), { status: 'done', completedAt: explicit }, NOW) + expect(next.completedAt).toBe(explicit) + }) + + it('状态不变时保留原 completedAt', () => { + const completedAt = '2026-10-02T00:00:00.000Z' + const next = applyTaskPatch(makeTask({ status: 'done', completedAt }), { title: '新标题' }, NOW) + expect(next.completedAt).toBe(completedAt) + }) +}) + +describe('applyTaskPatch 字段规则', () => { + it('刷新 updatedAt 且不修改入参', () => { + const current = makeTask() + const next = applyTaskPatch(current, { title: '新标题' }, NOW) + + expect(next.updatedAt).toBe(NOW) + expect(current.title).toBe('写方案') + expect(current.updatedAt).toBe('2026-10-01T00:00:00.000Z') + }) + + it('标题被清空时保留原标题', () => { + expect(applyTaskPatch(makeTask(), { title: ' ' }, NOW).title).toBe('写方案') + }) + + it('标签去空白并去重', () => { + const next = applyTaskPatch(makeTask(), { tags: [' 前端 ', '前端', '', '设计'] }, NOW) + expect(next.tags).toEqual(['前端', '设计']) + }) + + it('未传 tags 时保留原标签', () => { + expect(applyTaskPatch(makeTask(), { priority: 3 }, NOW).tags).toEqual(['设计']) + }) + + it('patch 里的 sortOrder 会被采用', () => { + expect(applyTaskPatch(makeTask(), { sortOrder: 4096 }, NOW).sortOrder).toBe(4096) + }) +}) + +describe('批量 patch 辅助', () => { + it('标签按增删计算,不影响其它标签', () => { + expect(tagsAfterBatchPatch(['a', 'b'], { addTags: ['c'], removeTags: ['a'] })).toEqual([ + 'b', + 'c', + ]) + }) + + it('添加已存在的标签不会重复', () => { + expect(tagsAfterBatchPatch(['a'], { addTags: ['a'] })).toEqual(['a']) + }) + + it('没有标签字段时原样返回', () => { + expect(tagsAfterBatchPatch(['a'], { priority: 3 })).toEqual(['a']) + }) + + it('空 patch 判定', () => { + expect(isBatchPatchEmpty({})).toBe(true) + expect(isBatchPatchEmpty({ addTags: [] })).toBe(true) + expect(isBatchPatchEmpty({ priority: 1 })).toBe(false) + expect(isBatchPatchEmpty({ archived: false })).toBe(false) + }) +}) diff --git a/src/utils/taskPatch.ts b/src/utils/taskPatch.ts new file mode 100644 index 0000000..cc0f3af --- /dev/null +++ b/src/utils/taskPatch.ts @@ -0,0 +1,85 @@ +/** + * 任务变更的状态流转规则 + * + * 单条编辑(详情抽屉)与批量编辑(多选操作条)必须共享同一套规则, + * 否则很容易出现「详情里改状态会写 completedAt,批量改却不会」这类不一致。 + * 因此把「按 patch 生成新任务」抽成纯函数,store 只负责定位任务、算位置与落盘。 + */ + +import { normalizeTags, applyTagChanges } from '@/utils/tags' +import type { ColumnId, Priority, Task } from '@/types/task' + +/** 单条更新允许改的字段:id 与 createdAt 不可变 */ +export type TaskPatch = Partial> + +/** + * 批量更新使用的 patch。 + * + * 标签刻意采用「增 / 删」而不是「覆盖」:多选后统一加一个标签时, + * 不应该把每条任务原有的其它标签一起抹掉。 + */ +export interface BatchPatch { + status?: ColumnId + priority?: Priority + /** null 表示清空截止日期 */ + dueDate?: string | null + addTags?: readonly string[] + removeTags?: readonly string[] + /** true 归档,false 取消归档 */ + archived?: boolean +} + +/** + * 应用一次 patch,返回新任务对象(不修改入参)。 + * + * 职责: + * - 状态进出「已完成」时写入 / 清除 completedAt + * - 刷新 updatedAt + * - 标签去重、trim + * - 标题被清空时保留原标题 + * + * 不负责 sortOrder 的换列计算 —— 那需要目标列的全部任务, + * 由调用方(store)算好放进 patch。 + */ +export function applyTaskPatch(current: Task, patch: TaskPatch, now: string): Task { + const nextStatus = patch.status ?? current.status + + let completedAt = current.completedAt + if (patch.completedAt !== undefined) { + completedAt = patch.completedAt + } else if (nextStatus === 'done' && current.status !== 'done') { + completedAt = now + } else if (nextStatus !== 'done' && current.status === 'done') { + completedAt = null + } + + const title = patch.title === undefined ? current.title : patch.title.trim() || current.title + + return { + ...current, + ...patch, + title, + status: nextStatus, + tags: patch.tags ? normalizeTags(patch.tags) : current.tags, + completedAt, + updatedAt: now, + } +} + +/** 把批量 patch 中与字段无关的「标签增删」折算成新的标签数组 */ +export function tagsAfterBatchPatch(current: readonly string[], patch: BatchPatch): string[] { + if (!patch.addTags && !patch.removeTags) return [...current] + return applyTagChanges(current, patch.addTags ?? [], patch.removeTags ?? []) +} + +/** 批量 patch 是否真的包含需要写入的字段 */ +export function isBatchPatchEmpty(patch: BatchPatch): boolean { + return ( + patch.status === undefined && + patch.priority === undefined && + patch.dueDate === undefined && + !patch.addTags?.length && + !patch.removeTags?.length && + patch.archived === undefined + ) +} diff --git a/src/utils/today.spec.ts b/src/utils/today.spec.ts index 4082b27..49fbe02 100644 --- a/src/utils/today.spec.ts +++ b/src/utils/today.spec.ts @@ -20,6 +20,7 @@ function makeTask(overrides: Partial = {}): Task { updatedAt: '2026-10-01T00:00:00.000Z', completedAt: null, archivedAt: null, + deletedAt: null, ...overrides, } } diff --git a/src/views/BoardView.spec.ts b/src/views/BoardView.spec.ts index c930806..bf4160d 100644 --- a/src/views/BoardView.spec.ts +++ b/src/views/BoardView.spec.ts @@ -205,7 +205,7 @@ describe('BoardView 详情抽屉', () => { await deleteButton?.trigger('click') await nextTick() - expect(store.tasks).toHaveLength(0) + expect(store.activeTasks).toHaveLength(0) expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false) }) }) @@ -698,7 +698,7 @@ describe('BoardView 多选与批量删除', () => { ?.trigger('click') await nextTick() - expect(store.tasks.map((item) => item.title)).toEqual(['B']) + expect(store.activeTasks.map((item) => item.title)).toEqual(['B']) // 删除后自动退出多选 expect(wrapper.text()).not.toContain('已选') @@ -780,3 +780,76 @@ describe('BoardView 多选与批量删除', () => { expect(wrapper.text()).toContain('已选 0 个任务') }) }) + +describe('BoardView 批量编辑', () => { + function exactButton(wrapper: ReturnType, text: string) { + return wrapper.findAll('button').find((node) => node.text() === text) + } + + async function enterSelectionAndSelectAll(wrapper: ReturnType) { + await exactButton(wrapper, '多选')?.trigger('click') + await nextTick() + await wrapper + .findAll('button') + .find((node) => node.text().includes('全选')) + ?.trigger('click') + await nextTick() + } + + it('批量设置优先级后全部生效并提示数量', async () => { + const { pinia, store } = setup() + store.createTasks([{ title: 'A' }, { title: 'B' }]) + const wrapper = mountBoard(pinia) + await enterSelectionAndSelectAll(wrapper) + + await wrapper.find('button[aria-label="设为高优先级"]').trigger('click') + await nextTick() + + expect(store.activeTasks.every((task) => task.priority === 3)).toBe(true) + expect(useToastStore().toasts.at(-1)?.message).toBe('已将 2 个任务设为「高」优先级') + }) + + it('批量移动到进行中,卡片换列', async () => { + const { pinia, store } = setup() + store.createTasks([{ title: 'A' }, { title: 'B' }]) + const wrapper = mountBoard(pinia) + await enterSelectionAndSelectAll(wrapper) + + await wrapper.find('select[aria-label="移动到"]').setValue('doing') + await nextTick() + + expect(store.byColumn.todo).toHaveLength(0) + expect(store.byColumn.doing.map((task) => task.title)).toEqual(['A', 'B']) + }) + + it('批量添加标签不会清空原有标签', async () => { + const { pinia, store } = setup() + store.createTask({ title: 'A', tags: ['已有'] }) + const wrapper = mountBoard(pinia) + await enterSelectionAndSelectAll(wrapper) + + await wrapper.find('input[aria-label="标签"]').setValue('前端') + await exactButton(wrapper, '添加')?.trigger('click') + await nextTick() + + expect(store.activeTasks[0]?.tags).toEqual(['已有', '前端']) + }) + + it('批量归档后卡片消失,撤销可恢复', async () => { + const { pinia, store } = setup() + store.createTasks([{ title: 'A' }, { title: 'B' }]) + const wrapper = mountBoard(pinia) + await enterSelectionAndSelectAll(wrapper) + + await exactButton(wrapper, '归档')?.trigger('click') + await nextTick() + + expect(store.activeTasks.every((task) => task.archivedAt !== null)).toBe(true) + expect(wrapper.text()).not.toContain('已选') + + const toast = useToastStore() + expect(toast.toasts.at(-1)?.message).toBe('已归档 2 个任务') + toast.toasts.at(-1)?.action?.run() + expect(store.activeTasks.every((task) => task.archivedAt === null)).toBe(true) + }) +}) diff --git a/src/views/BoardView.vue b/src/views/BoardView.vue index aee85c8..790f619 100644 --- a/src/views/BoardView.vue +++ b/src/views/BoardView.vue @@ -12,7 +12,7 @@ import { useFilterStore } from '@/stores/filters' import { useTaskStore } from '@/stores/tasks' import { useToastStore } from '@/stores/toast' import { useUiStore } from '@/stores/ui' -import { COLUMNS, type ColumnId, type MoveDirection } from '@/types/task' +import { COLUMNS, PRIORITY_LABELS, type ColumnId, type MoveDirection, type Priority } from '@/types/task' import { findArchivableTasks } from '@/utils/archive' import { filterTasks } from '@/utils/filter' import { groupByColumn } from '@/utils/sortOrder' @@ -25,7 +25,9 @@ const { filter, includeArchived } = storeToRefs(filters) /** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */ const editingId = ref(null) -const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null) +const editingTask = computed( + () => store.activeTasks.find((task) => task.id === editingId.value) ?? null, +) /** 批量添加面板 */ const batchOpen = ref(false) @@ -43,7 +45,7 @@ function setColumnRef(element: unknown, index: number): void { } /** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */ -const visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value))) +const visibleColumns = computed(() => groupByColumn(filterTasks(store.activeTasks, filter.value))) /** * 每列被隐藏的数量(被筛选掉 + 已归档)。 @@ -70,7 +72,7 @@ const hiddenTotal = computed(() => */ const archivedHiddenTotal = computed(() => { if (includeArchived.value) return 0 - const archived = store.tasks.filter((task) => task.archivedAt !== null) + const archived = store.activeTasks.filter((task) => task.archivedAt !== null) // filter.includeArchived 为 false 时会把归档任务直接排除,这里显式放开 return filterTasks(archived, { ...filter.value, includeArchived: true }).length }) @@ -94,7 +96,7 @@ const hiddenNote = computed(() => { return `已隐藏 ${hiddenTotal.value} 个${detail}` }) -const archivableTasks = computed(() => findArchivableTasks(store.tasks)) +const archivableTasks = computed(() => findArchivableTasks(store.activeTasks)) function archiveCompleted(): void { const targets = archivableTasks.value @@ -127,7 +129,7 @@ useKeyboardShortcuts({ }) function toggleTask(id: string): void { - const task = store.tasks.find((item) => item.id === id) + const task = store.activeTasks.find((item) => item.id === id) if (!task) return store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' }) } @@ -152,7 +154,7 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number } * 上下调顺序则要求该列渲染的是全量,否则下标没有意义。 */ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void { - const task = store.tasks.find((item) => item.id === payload.id) + const task = store.activeTasks.find((item) => item.id === payload.id) if (!task) return if (payload.direction === 'left' || payload.direction === 'right') { @@ -183,7 +185,9 @@ const visibleTaskIds = computed(() => ) /** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 id */ -const selectedTasks = computed(() => store.tasks.filter((task) => selectedIds.value.has(task.id))) +const selectedTasks = computed(() => + store.activeTasks.filter((task) => selectedIds.value.has(task.id)), +) const selectedCount = computed(() => selectedTasks.value.length) function toggleSelectMode(): void { @@ -226,6 +230,63 @@ function removeSelected(): void { }) } +/* ---------- 批量编辑 ---------- */ + +/** 一次批量操作只写一次仓库;返回实际更新数量便于提示 */ +function selectedTaskIds(): string[] { + return selectedTasks.value.map((task) => task.id) +} + +function batchMove(status: ColumnId): void { + const count = store.updateTasks(selectedTaskIds(), { status }) + if (count === 0) return + const title = COLUMNS.find((column) => column.id === status)?.title ?? status + toast.show({ message: `已把 ${count} 个任务移动到「${title}」` }) +} + +function batchPriority(priority: Priority): void { + const count = store.updateTasks(selectedTaskIds(), { priority }) + if (count === 0) return + toast.show({ message: `已将 ${count} 个任务设为「${PRIORITY_LABELS[priority]}」优先级` }) +} + +function batchAddTag(tag: string): void { + const count = store.updateTasks(selectedTaskIds(), { addTags: [tag] }) + if (count === 0) return + toast.show({ message: `已为 ${count} 个任务添加标签「${tag}」` }) +} + +function batchRemoveTag(tag: string): void { + const count = store.updateTasks(selectedTaskIds(), { removeTags: [tag] }) + if (count === 0) return + toast.show({ message: `已从 ${count} 个任务移除标签「${tag}」` }) +} + +function batchDue(date: string | null): void { + const count = store.updateTasks(selectedTaskIds(), { dueDate: date }) + if (count === 0) return + toast.show({ + message: date + ? `已将 ${count} 个任务的截止日期设为 ${date}` + : `已清除 ${count} 个任务的截止日期`, + }) +} + +/** 批量归档后卡片会从看板消失,因此退出多选并给一次撤销入口 */ +function batchArchive(): void { + const ids = selectedTaskIds() + if (ids.length === 0) return + + const count = store.archiveTasks(ids) + if (count === 0) return + + exitSelection() + toast.show({ + message: `已归档 ${count} 个任务`, + action: { label: '撤销', run: () => store.unarchiveTasks(ids) }, + }) +} + /** * 筛选条件一变就清空选择:选择必须始终对应「眼前看得到的卡片」, * 否则会出现「删掉了看不见的任务」这种难以预料的结果。 @@ -244,16 +305,24 @@ watch(filter, () => { @archive="archiveCompleted" @batch-add="batchOpen = true" @toggle-select="toggleSelectMode" + @manage-tags="ui.openTags()" />