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
+65 -9
View File
@@ -6,12 +6,18 @@ import { formatDueDate, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3
const props = defineProps<{ task: Task }>()
const props = defineProps<{
task: Task
/** 多选模式:卡片改为可勾选,点击是「选中」而不是「打开详情」 */
selectable?: boolean
selected?: boolean
}>()
const emit = defineEmits<{
toggle: [id: string]
open: [id: string]
keymove: [payload: { id: string; direction: MoveDirection }]
select: [id: string]
}>()
/** 方向键映射;左右换列,上下调顺序 */
@@ -22,6 +28,15 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
ArrowDown: 'down',
}
/** 多选态下点击/回车都是「切换选中」;平时是「打开详情」 */
function activate(): void {
if (props.selectable) {
emit('select', props.task.id)
return
}
emit('open', props.task.id)
}
/**
* 卡片获得焦点后的键盘操作。
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
@@ -29,10 +44,13 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('open', props.task.id)
activate()
return
}
// 多选态不做方向键移动:此时拖拽与排序都已停用,避免误操作打乱顺序
if (props.selectable) return
const direction = MOVE_KEYS[event.key]
if (!direction) return
@@ -45,6 +63,19 @@ const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
/**
* 卡片外观。选中态与「已完成」都要覆盖边框/背景,
* 因此这些颜色放在条件分支里,而不是和基础类各写一份靠优先级取胜。
*/
const cardClass = computed(() => {
if (props.selected) {
return 'border-sky-400 bg-sky-50 dark:border-sky-600 dark:bg-sky-950/40'
}
return isDone.value
? 'border-slate-200 bg-white opacity-60 dark:border-zinc-800 dark:bg-zinc-900'
: 'border-slate-200 bg-white hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700'
})
/**
* 优先级用左侧色条表达,而不是一枚彩色胶囊。
* 色条几乎不占空间,扫视一列卡片时却比文字更快分辨出轻重。
@@ -74,22 +105,47 @@ const priorityTextClass = computed(() => {
<template>
<article
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150 dark:border-zinc-800 dark:bg-zinc-900"
:class="
isDone
? 'opacity-60'
: 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:hover:border-zinc-700'
"
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-150"
:class="cardClass"
:data-task-id="task.id"
:role="selectable ? 'checkbox' : undefined"
:aria-checked="selectable ? selected === true : undefined"
:aria-label="selectable ? `选择任务「${task.title}」` : undefined"
tabindex="0"
@click="emit('open', task.id)"
@click="activate"
@keydown="onKeydown"
>
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
<div class="py-2 pr-2.5 pl-3.5">
<div class="flex items-start gap-2">
<!--
多选态下用一枚纯装饰的勾选框:真正的可交互元素是整张卡片,
这样读屏与键盘只有「一个」可聚焦控件,不会出现嵌套按钮。
-->
<span
v-if="selectable"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border transition"
:class="
selected
? 'border-sky-500 bg-sky-500 text-white'
: 'border-slate-300 bg-white dark:border-zinc-600 dark:bg-zinc-800'
"
aria-hidden="true"
>
<svg v-if="selected" viewBox="0 0 12 12" class="size-2.5" fill="none">
<path
d="m2.5 6.2 2.2 2.2 4.8-4.8"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
<button
v-else
type="button"
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
:class="