feat(board): 支持批量添加与批量删除任务
- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期 - 快速新增支持粘贴多行文本,直接拆成多条任务 - 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原 - 批量创建与删除走 store 的批量方法,一次落盘、一次广播 - 补充单元测试与 E2E 用例
This commit is contained in:
+89
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user