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
+77 -8
View File
@@ -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<string | null>(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()"
/>
<BatchSelectionBar
v-if="selecting"
:count="selectedCount"
:total="visibleTaskIds.length"
:tags="store.allTags"
@select-all="selectAllVisible"
@clear="clearSelection"
@remove="removeSelected"
@done="exitSelection"
@move="batchMove"
@priority="batchPriority"
@add-tag="batchAddTag"
@remove-tag="batchRemoveTag"
@due="batchDue"
@archive="batchArchive"
/>
<!--