feat(board): 支持批量添加与批量删除任务

- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期
- 快速新增支持粘贴多行文本,直接拆成多条任务
- 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原
- 批量创建与删除走 store 的批量方法,一次落盘、一次广播
- 补充单元测试与 E2E 用例
This commit is contained in:
chenshoulin
2026-10-19 17:21:42 +08:00
parent 4aba003a9e
commit 2d6e24dc91
21 changed files with 1518 additions and 37 deletions
+89 -2
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import BoardColumn from '@/components/BoardColumn.vue'
import BoardToolbar from '@/components/BoardToolbar.vue'
import BatchAddDrawer from '@/components/BatchAddDrawer.vue'
import BatchSelectionBar from '@/components/BatchSelectionBar.vue'
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useFilterStore } from '@/stores/filters'
@@ -25,6 +27,13 @@ const { filter, includeArchived } = storeToRefs(filters)
const editingId = ref<string | null>(null)
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
/** 批量添加面板 */
const batchOpen = ref(false)
/** 多选模式与已选任务;多选时卡片改为勾选、拖拽停用 */
const selecting = ref(false)
const selectedIds = ref<Set<string>>(new Set())
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
@@ -95,6 +104,9 @@ function archiveCompleted(): void {
const count = store.archiveTasks(ids)
if (count === 0) return
// 归档后这些卡片默认不可见,留在选择里会让「已选」与眼前看到的对不上
if (selecting.value) clearSelection()
toast.show({
message: `已归档 ${count} 个已完成任务`,
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
@@ -104,7 +116,7 @@ function archiveCompleted(): void {
/** 有面板打开时不响应全局快捷键,避免误触 */
function whenNoPanel(action: () => void): () => void {
return () => {
if (editingId.value !== null || ui.anyPanelOpen) return
if (editingId.value !== null || batchOpen.value || selecting.value || ui.anyPanelOpen) return
action()
}
}
@@ -162,6 +174,65 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
document.querySelector<HTMLElement>(`[data-task-id="${payload.id}"]`)?.focus()
})
}
/* ---------- 多选与批量删除 ---------- */
/** 当前可见(筛选后)的任务 id,全选以它为准:所见即所选 */
const visibleTaskIds = computed(() =>
COLUMNS.flatMap((column) => visibleColumns.value[column.id].map((task) => task.id)),
)
/** 真正存在的已选任务:跨标签页同步等情况下可能出现已不存在的 id */
const selectedTasks = computed(() => store.tasks.filter((task) => selectedIds.value.has(task.id)))
const selectedCount = computed(() => selectedTasks.value.length)
function toggleSelectMode(): void {
if (selecting.value) exitSelection()
else selecting.value = true
}
function exitSelection(): void {
selecting.value = false
selectedIds.value = new Set()
}
function toggleSelect(id: string): void {
const next = new Set(selectedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedIds.value = next
}
function selectAllVisible(): void {
selectedIds.value = new Set(visibleTaskIds.value)
}
function clearSelection(): void {
selectedIds.value = new Set()
}
/**
* 删除选中的任务。不做二次确认 —— 与单条删除同一套约定:
* 立即删除 + Toast 撤销,误删可以整批还原(连原顺序一起)。
*/
function removeSelected(): void {
const removed = store.removeTasks(selectedTasks.value.map((task) => task.id))
if (removed.length === 0) return
exitSelection()
toast.show({
message: `已删除 ${removed.length} 个任务`,
action: { label: '撤销', run: () => store.restoreTasks(removed) },
})
}
/**
* 筛选条件一变就清空选择:选择必须始终对应「眼前看得到的卡片」,
* 否则会出现「删掉了看不见的任务」这种难以预料的结果。
*/
watch(filter, () => {
if (selectedIds.value.size > 0) clearSelection()
})
</script>
<template>
@@ -169,9 +240,21 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
<BoardToolbar
ref="toolbarRef"
:hidden-note="hiddenNote"
:selecting="selecting"
@archive="archiveCompleted"
@batch-add="batchOpen = true"
@toggle-select="toggleSelectMode"
/>
<BatchSelectionBar
v-if="selecting"
:count="selectedCount"
:total="visibleTaskIds.length"
@select-all="selectAllVisible"
@clear="clearSelection"
@remove="removeSelected"
@done="exitSelection"
/>
<!--
移动端:整块区域滚动,各列按内容自然高度排布;
@@ -185,13 +268,17 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
:column="column"
:tasks="visibleColumns[column.id]"
:sort-disabled="hiddenCounts[column.id] > 0"
:selectable="selecting"
:selected-ids="selectedIds"
@toggle="toggleTask"
@open="openTask"
@move="handleMove"
@keymove="handleKeyMove"
@select="toggleSelect"
/>
</div>
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
<BatchAddDrawer :open="batchOpen" @close="batchOpen = false" />
</div>
</template>