feat(board): 支持批量添加与批量删除任务
- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期 - 快速新增支持粘贴多行文本,直接拆成多条任务 - 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原 - 批量创建与删除走 store 的批量方法,一次落盘、一次广播 - 补充单元测试与 E2E 用例
This commit is contained in:
@@ -92,6 +92,66 @@ test('编辑任务时可以直接选已有标签', async ({ page }) => {
|
|||||||
await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计')
|
await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加要打通「工具栏 → 抽屉 → store → 看板」四层,单测只到组件边界,
|
||||||
|
* 所以这里走一遍真实点击:填清单、改目标列、提交、刷新后仍存在。
|
||||||
|
*/
|
||||||
|
test('批量添加:一次录入多行并出现在看板上', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: '批量添加' }).click()
|
||||||
|
|
||||||
|
const list = page.getByLabel('批量任务清单')
|
||||||
|
// 打开面板就该能直接粘贴,焦点应落在清单上
|
||||||
|
await expect(list).toBeFocused()
|
||||||
|
|
||||||
|
await list.fill('- 写周报\n- 评审 PR\n- 回归测试')
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: '进行中' }).click()
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: '高' }).click()
|
||||||
|
await page.getByRole('button', { name: '添加 3 个任务' }).click()
|
||||||
|
|
||||||
|
await expect(page.getByText('已添加 3 个任务')).toBeVisible()
|
||||||
|
|
||||||
|
const doing = page.locator('[data-column-id="doing"] article')
|
||||||
|
await expect(doing).toHaveCount(3)
|
||||||
|
// 卡片顺序与清单顺序一致(第 1 行在最上面)
|
||||||
|
expect(await doing.locator('h3').allInnerTexts()).toEqual(['写周报', '评审 PR', '回归测试'])
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(doing).toHaveCount(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量删除同样跨了「工具栏 → 卡片多选 → 操作条 → store → Toast」,
|
||||||
|
* 关键要验证三件事:勾选能选/能取消、删除后整批消失、撤销能还原。
|
||||||
|
*/
|
||||||
|
test('批量删除:多选后整批删除,撤销可还原', async ({ page }) => {
|
||||||
|
await addTask(page, '任务一')
|
||||||
|
await addTask(page, '任务二')
|
||||||
|
await addTask(page, '任务三')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '多选', exact: true }).click()
|
||||||
|
await expect(page.getByText('已选 0 个任务')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '全选 3 个' }).click()
|
||||||
|
await expect(page.getByText('已选 3 个任务')).toBeVisible()
|
||||||
|
|
||||||
|
// 再点一张取消勾选,验证「点选 / 取消」这一对行为
|
||||||
|
await page.locator('[data-column-id="todo"] article').filter({ hasText: '任务二' }).click()
|
||||||
|
await expect(page.getByText('已选 2 个任务')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '删除 2 个' }).click()
|
||||||
|
await expect(page.getByText('已删除 2 个任务')).toBeVisible()
|
||||||
|
|
||||||
|
const cards = page.locator('[data-column-id="todo"] article')
|
||||||
|
await expect(cards).toHaveCount(1)
|
||||||
|
await expect(cards.first()).toContainText('任务二')
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '撤销' }).click()
|
||||||
|
await expect(cards).toHaveCount(3)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(cards).toHaveCount(3)
|
||||||
|
})
|
||||||
|
|
||||||
test('删除后可撤销', async ({ page }) => {
|
test('删除后可撤销', async ({ page }) => {
|
||||||
await addTask(page, '待删除任务')
|
await addTask(page, '待删除任务')
|
||||||
await page.locator('article').filter({ hasText: '待删除任务' }).click()
|
await page.locator('article').filter({ hasText: '待删除任务' }).click()
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
import BatchAddDrawer from './BatchAddDrawer.vue'
|
||||||
|
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
function setup(open = true) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
|
||||||
|
// 抽屉会被 Teleport 到 body,测试中改为就地渲染以便断言
|
||||||
|
const wrapper = mount(BatchAddDrawer, {
|
||||||
|
props: { open },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonByText(wrapper: ReturnType<typeof setup>['wrapper'], text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BatchAddDrawer 录入', () => {
|
||||||
|
it('打开后显示多行清单输入框', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按行实时统计将添加的任务数', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('第一条\n\n- 第二条\n第三条')
|
||||||
|
|
||||||
|
expect(buttonByText(wrapper, '个任务')?.text()).toContain('添加 3 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清单为空时提交按钮不可用', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const button = buttonByText(wrapper, '添加 0 个任务')
|
||||||
|
|
||||||
|
expect(button).toBeDefined()
|
||||||
|
expect(button?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有空行时同样不可提交', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue(' \n\n')
|
||||||
|
|
||||||
|
expect(buttonByText(wrapper, '添加 0 个任务')?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BatchAddDrawer 提交', () => {
|
||||||
|
it('按清单顺序创建任务,第一条在最上面', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('第一条\n第二条\n第三条')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '添加 3 个任务')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '第三条'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目标列、优先级、截止日期与标签整批共用', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('第一条\n第二条')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '进行中')?.trigger('click')
|
||||||
|
await buttonByText(wrapper, '高')?.trigger('click')
|
||||||
|
await wrapper.find('input[type="date"]').setValue('2026-10-20')
|
||||||
|
|
||||||
|
const tagInput = wrapper.find('input[aria-label="添加标签"]')
|
||||||
|
await tagInput.setValue('前端')
|
||||||
|
await tagInput.trigger('keydown.enter')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||||
|
|
||||||
|
const created = store.byColumn.doing
|
||||||
|
expect(created.map((task) => task.title)).toEqual(['第一条', '第二条'])
|
||||||
|
expect(created[0]?.priority).toBe(3)
|
||||||
|
expect(created[0]?.dueDate).toBe('2026-10-20')
|
||||||
|
expect(created[0]?.tags).toEqual(['前端'])
|
||||||
|
expect(created[1]?.tags).toEqual(['前端'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('提交后请求关闭并提示数量', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('A\nB')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
expect(useToastStore().toasts.at(-1)?.message).toBe('已添加 2 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消不创建任何任务', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('A\nB')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('再次打开时清空上一次的残留', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('上一次的清单')
|
||||||
|
|
||||||
|
await wrapper.setProps({ open: false })
|
||||||
|
await wrapper.setProps({ open: true })
|
||||||
|
|
||||||
|
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('误提交时可以整批撤销', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
await wrapper.find('textarea').setValue('A\nB')
|
||||||
|
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||||
|
|
||||||
|
const action = useToastStore().toasts.at(-1)?.action
|
||||||
|
expect(action?.label).toBe('撤销')
|
||||||
|
action?.run()
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 批量添加任务
|
||||||
|
*
|
||||||
|
* 一行一条,整段粘贴即可录入一批 —— 从笔记、Markdown 清单、聊天记录里
|
||||||
|
* 直接复制过来就能用,行首的「-」「1.」这类列表符号会被自动去掉。
|
||||||
|
*
|
||||||
|
* 目标列、优先级、标签、截止日期是这一批**共用**的属性:批量录入时逐个
|
||||||
|
* 任务调字段不现实,而「先统一建出来、再单独改个别任务」正是看板的用法。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { COLUMNS, PRIORITY_LABELS, type ColumnId, type Priority } from '@/types/task'
|
||||||
|
import { parseTaskLines } from '@/utils/batch'
|
||||||
|
|
||||||
|
import DatePicker from './DatePicker.vue'
|
||||||
|
import SideDrawer from './SideDrawer.vue'
|
||||||
|
import TagInput from './TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const PRIORITIES: readonly Priority[] = [1, 2, 3]
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const status = ref<ColumnId>('todo')
|
||||||
|
const priority = ref<Priority>(2)
|
||||||
|
const dueDate = ref<string | null>(null)
|
||||||
|
const tags = ref<string[]>([])
|
||||||
|
|
||||||
|
const titles = computed(() => parseTaskLines(text.value))
|
||||||
|
const canSubmit = computed(() => titles.value.length > 0)
|
||||||
|
|
||||||
|
/** 每次打开都回到干净状态,避免上一次的残留被误提交 */
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return
|
||||||
|
text.value = ''
|
||||||
|
status.value = 'todo'
|
||||||
|
priority.value = 2
|
||||||
|
dueDate.value = null
|
||||||
|
tags.value = []
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
|
||||||
|
const created = store.createTasks(
|
||||||
|
titles.value.map((title) => ({
|
||||||
|
title,
|
||||||
|
status: status.value,
|
||||||
|
priority: priority.value,
|
||||||
|
dueDate: dueDate.value,
|
||||||
|
tags: [...tags.value],
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
if (created.length === 0) return
|
||||||
|
|
||||||
|
const ids = created.map((task) => task.id)
|
||||||
|
toast.show({
|
||||||
|
message: `已添加 ${created.length} 个任务`,
|
||||||
|
action: { label: '撤销', run: () => store.removeTasks(ids) },
|
||||||
|
})
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SideDrawer :open="open" title="批量添加" initial-focus="textarea" @close="emit('close')">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">
|
||||||
|
任务清单
|
||||||
|
<span class="ml-1 text-xs font-normal text-slate-400 dark:text-zinc-500">
|
||||||
|
每行一条,可直接粘贴
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
v-model="text"
|
||||||
|
rows="8"
|
||||||
|
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm leading-6 text-slate-700 outline-none focus:border-sky-400 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-sky-600"
|
||||||
|
placeholder="写周报 评审 PR 回归测试"
|
||||||
|
aria-label="批量任务清单"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-slate-400 dark:text-zinc-500">
|
||||||
|
空行会被忽略;行首的「-」「*」「1.」等列表符号会自动去掉
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
|
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">添加到</legend>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="column in COLUMNS"
|
||||||
|
:key="column.id"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border px-3 py-1 text-sm transition"
|
||||||
|
:class="
|
||||||
|
status === column.id
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="status === column.id"
|
||||||
|
@click="status = column.id"
|
||||||
|
>
|
||||||
|
{{ column.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
|
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-zinc-400">优先级</legend>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="value in PRIORITIES"
|
||||||
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border px-3 py-1 text-sm transition"
|
||||||
|
:class="
|
||||||
|
priority === value
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="priority === value"
|
||||||
|
@click="priority = value"
|
||||||
|
>
|
||||||
|
{{ PRIORITY_LABELS[value] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">截止日期</span>
|
||||||
|
<DatePicker v-model="dueDate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600 dark:text-zinc-400">标签</span>
|
||||||
|
<TagInput v-model="tags" :suggestions="store.allTags" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs text-slate-500 dark:text-zinc-400">
|
||||||
|
将添加
|
||||||
|
<strong class="font-semibold text-slate-700 dark:text-zinc-200">
|
||||||
|
{{ titles.length }}
|
||||||
|
</strong>
|
||||||
|
个任务
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-sky-600 dark:hover:bg-sky-500"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
添加 {{ titles.length }} 个任务
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SideDrawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import BatchSelectionBar from './BatchSelectionBar.vue'
|
||||||
|
|
||||||
|
function setup(count = 0, total = 0) {
|
||||||
|
const wrapper = mount(BatchSelectionBar, { props: { count, total } })
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonByText(wrapper: ReturnType<typeof setup>, text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BatchSelectionBar 展示', () => {
|
||||||
|
it('显示已选数量', () => {
|
||||||
|
expect(setup(2, 5).text()).toContain('已选 2 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全选按钮带上可见任务总数', () => {
|
||||||
|
expect(buttonByText(setup(0, 5), '全选')?.text()).toContain('全选 5 个')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除按钮带上已选数量', () => {
|
||||||
|
expect(buttonByText(setup(3, 5), '删除')?.text()).toContain('删除 3 个')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('作为工具条暴露给读屏', () => {
|
||||||
|
const bar = setup(1, 1)
|
||||||
|
expect(bar.attributes('role')).toBe('toolbar')
|
||||||
|
expect(bar.attributes('aria-label')).toBe('批量操作')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BatchSelectionBar 交互', () => {
|
||||||
|
it('全选 emit select-all', async () => {
|
||||||
|
const wrapper = setup(0, 3)
|
||||||
|
await buttonByText(wrapper, '全选')?.trigger('click')
|
||||||
|
expect(wrapper.emitted('select-all')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空选择 emit clear', async () => {
|
||||||
|
const wrapper = setup(2, 3)
|
||||||
|
await buttonByText(wrapper, '清空选择')?.trigger('click')
|
||||||
|
expect(wrapper.emitted('clear')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('完成 emit done', async () => {
|
||||||
|
const wrapper = setup(2, 3)
|
||||||
|
await buttonByText(wrapper, '完成')?.trigger('click')
|
||||||
|
expect(wrapper.emitted('done')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除 emit remove', async () => {
|
||||||
|
const wrapper = setup(2, 3)
|
||||||
|
await buttonByText(wrapper, '删除')?.trigger('click')
|
||||||
|
expect(wrapper.emitted('remove')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BatchSelectionBar 禁用态', () => {
|
||||||
|
it('未选中时不能删除', () => {
|
||||||
|
expect(buttonByText(setup(0, 3), '删除')?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未选中时不能清空', () => {
|
||||||
|
expect(buttonByText(setup(0, 3), '清空选择')?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已全选时全选按钮不可再点', () => {
|
||||||
|
expect(buttonByText(setup(3, 3), '全选')?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有可见任务时全选不可用', () => {
|
||||||
|
expect(buttonByText(setup(0, 0), '全选')?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 多选操作条
|
||||||
|
*
|
||||||
|
* 只在多选模式出现,负责「选了多少 / 全选 / 清空 / 删除」。
|
||||||
|
* 删除不做二次确认:与单条删除一致,靠 Toast 的「撤销」兜底,
|
||||||
|
* 误删的代价远低于每次批量操作都弹窗确认。
|
||||||
|
*/
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/** 已选数量 */
|
||||||
|
count: number
|
||||||
|
/** 当前可见(筛选后)的任务总数 */
|
||||||
|
total: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-all': []
|
||||||
|
clear: []
|
||||||
|
remove: []
|
||||||
|
done: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-x-2 gap-y-1.5 rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-900 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-200"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="批量操作"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
已选
|
||||||
|
<strong class="font-semibold">{{ count }}</strong>
|
||||||
|
个任务
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-sky-200 bg-white/70 px-2 py-1 text-sky-700 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
|
||||||
|
:disabled="total === 0 || count === total"
|
||||||
|
@click="emit('select-all')"
|
||||||
|
>
|
||||||
|
全选 {{ total }} 个
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 text-sky-700 underline-offset-2 transition hover:underline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:no-underline dark:text-sky-200"
|
||||||
|
:disabled="count === 0"
|
||||||
|
@click="emit('clear')"
|
||||||
|
>
|
||||||
|
清空选择
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-sky-200 bg-white/70 px-2.5 py-1 text-sky-700 transition hover:bg-white dark:border-sky-800 dark:bg-sky-900/40 dark:text-sky-200 dark:hover:bg-sky-900/70"
|
||||||
|
@click="emit('done')"
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-rose-300 bg-white px-2.5 py-1 font-medium text-rose-600 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-white dark:border-rose-800 dark:bg-zinc-900 dark:text-rose-400 dark:hover:bg-rose-950"
|
||||||
|
:disabled="count === 0"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
删除 {{ count }} 个
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -214,3 +214,47 @@ describe('BoardColumn 拖拽事件接线', () => {
|
|||||||
expect(options.filter).toBe('button')
|
expect(options.filter).toBe('button')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BoardColumn 多选模式', () => {
|
||||||
|
function mountSelectable(tasks: Task[], selectedIds: Set<string>) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
|
||||||
|
|
||||||
|
const wrapper = mount(BoardColumn, {
|
||||||
|
props: { column: TODO_COLUMN, tasks: store.byColumn.todo, selectable: true, selectedIds },
|
||||||
|
global: { plugins: [pinia] },
|
||||||
|
})
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('卡片变为可勾选,selectedIds 决定勾选态', () => {
|
||||||
|
const { wrapper } = mountSelectable(
|
||||||
|
[makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })],
|
||||||
|
new Set(['task-2']),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-task-id="task-1"]').attributes('aria-checked')).toBe('false')
|
||||||
|
expect(wrapper.find('[data-task-id="task-2"]').attributes('aria-checked')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击卡片 emit select 并带上 id', async () => {
|
||||||
|
const { wrapper } = mountSelectable([makeTask({ id: 'task-1' })], new Set())
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('select')).toEqual([['task-1']])
|
||||||
|
expect(wrapper.emitted('open')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选时禁止拖拽(同列与跨列都不允许)', () => {
|
||||||
|
const { wrapper } = mountSelectable([makeTask()], new Set())
|
||||||
|
const same = wrapper.find('[data-column-id="todo"]').element
|
||||||
|
const other = document.createElement('div')
|
||||||
|
|
||||||
|
const onMove = lastDragOptions().onMove
|
||||||
|
expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false)
|
||||||
|
expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ import { resolveDrop, shouldAllowMove } from '@/utils/drag'
|
|||||||
import QuickAddTask from './QuickAddTask.vue'
|
import QuickAddTask from './QuickAddTask.vue'
|
||||||
import TaskCard from './TaskCard.vue'
|
import TaskCard from './TaskCard.vue'
|
||||||
|
|
||||||
const props = defineProps<{ column: ColumnDef; tasks: Task[]; sortDisabled?: boolean }>()
|
const props = defineProps<{
|
||||||
|
column: ColumnDef
|
||||||
|
tasks: Task[]
|
||||||
|
sortDisabled?: boolean
|
||||||
|
/** 多选模式:卡片改为可勾选,并且整体禁用拖拽 */
|
||||||
|
selectable?: boolean
|
||||||
|
selectedIds?: ReadonlySet<string>
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
toggle: [id: string]
|
toggle: [id: string]
|
||||||
open: [id: string]
|
open: [id: string]
|
||||||
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
||||||
keymove: [payload: { id: string; direction: MoveDirection }]
|
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||||
|
select: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 每列一个色点:三列在余光里也能区分开 */
|
/** 每列一个色点:三列在余光里也能区分开 */
|
||||||
@@ -46,7 +54,9 @@ useDraggable(listRef, {
|
|||||||
ghostClass: 'opacity-40',
|
ghostClass: 'opacity-40',
|
||||||
// 在完成勾选按钮上按下时不启动拖拽
|
// 在完成勾选按钮上按下时不启动拖拽
|
||||||
filter: 'button',
|
filter: 'button',
|
||||||
onMove: (event) => shouldAllowMove(event.from, event.to, props.sortDisabled === true),
|
onMove: (event) =>
|
||||||
|
props.selectable !== true &&
|
||||||
|
shouldAllowMove(event.from, event.to, props.sortDisabled === true),
|
||||||
onEnd: (event) => {
|
onEnd: (event) => {
|
||||||
const drop = resolveDrop(event)
|
const drop = resolveDrop(event)
|
||||||
if (!drop) return
|
if (!drop) return
|
||||||
@@ -81,9 +91,12 @@ useDraggable(listRef, {
|
|||||||
v-for="task in tasks"
|
v-for="task in tasks"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
|
:selectable="selectable === true"
|
||||||
|
:selected="selectedIds?.has(task.id) ?? false"
|
||||||
@toggle="emit('toggle', $event)"
|
@toggle="emit('toggle', $event)"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
@keymove="emit('keymove', $event)"
|
@keymove="emit('keymove', $event)"
|
||||||
|
@select="emit('select', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -188,3 +188,46 @@ describe('BoardToolbar 归档', () => {
|
|||||||
expect(filters.includeArchived).toBe(false)
|
expect(filters.includeArchived).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 批量添加', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击「批量添加」emit batch-add', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '批量添加')?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('batch-add')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 多选', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 文案精确匹配:「多选」是「退出多选」的子串 */
|
||||||
|
function exactButton(wrapper: ReturnType<typeof setup>['wrapper'], text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('点击「多选」emit toggle-select', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
|
||||||
|
await exactButton(wrapper, '多选')?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('toggle-select')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting 为 true 时按钮变成「退出多选」并标记为按下', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.setProps({ selecting: true })
|
||||||
|
|
||||||
|
const button = exactButton(wrapper, '退出多选')
|
||||||
|
expect(button).toBeDefined()
|
||||||
|
expect(button?.attributes('aria-pressed')).toBe('true')
|
||||||
|
expect(exactButton(wrapper, '多选')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { isDueToday, isOverdue } from '@/utils/date'
|
|||||||
|
|
||||||
import TagFilter from './TagFilter.vue'
|
import TagFilter from './TagFilter.vue'
|
||||||
|
|
||||||
defineProps<{ hiddenNote?: string }>()
|
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
|
||||||
|
|
||||||
const emit = defineEmits<{ archive: [] }>()
|
const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
const filters = useFilterStore()
|
const filters = useFilterStore()
|
||||||
@@ -133,6 +133,28 @@ defineExpose({ focusSearch })
|
|||||||
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 items-center rounded-md border border-slate-200 bg-white px-2.5 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800"
|
||||||
|
@click="emit('batch-add')"
|
||||||
|
>
|
||||||
|
批量添加
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 items-center rounded-md border px-2.5 text-xs transition"
|
||||||
|
:class="
|
||||||
|
selecting
|
||||||
|
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||||
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-800'
|
||||||
|
"
|
||||||
|
:aria-pressed="selecting === true"
|
||||||
|
@click="emit('toggle-select')"
|
||||||
|
>
|
||||||
|
{{ selecting ? '退出多选' : '多选' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="archivableCount > 0"
|
v-if="archivableCount > 0"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
|
|
||||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { ColumnId } from '@/types/task'
|
import type { ColumnId } from '@/types/task'
|
||||||
|
|
||||||
import QuickAddTask from './QuickAddTask.vue'
|
import QuickAddTask from './QuickAddTask.vue'
|
||||||
@@ -98,3 +99,104 @@ describe('QuickAddTask', () => {
|
|||||||
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
|
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 粘贴多行文本 = 批量添加。
|
||||||
|
* 这里手工构造 paste 事件:jsdom 没有可用的 Clipboard API,
|
||||||
|
* 而组件只依赖 `clipboardData.getData('text')` 这一小块接口。
|
||||||
|
*/
|
||||||
|
function paste(wrapper: ReturnType<typeof setup>['wrapper'], text: string): boolean {
|
||||||
|
const event = new Event('paste', { bubbles: true, cancelable: true })
|
||||||
|
Object.defineProperty(event, 'clipboardData', { value: { getData: () => text } })
|
||||||
|
wrapper.find('input').element.dispatchEvent(event)
|
||||||
|
return event.defaultPrevented
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QuickAddTask 批量粘贴', () => {
|
||||||
|
it('粘贴多行文本创建多条任务,并保持粘贴顺序', () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
|
||||||
|
expect(paste(wrapper, '写周报\n评审 PR\n回归测试')).toBe(true)
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR', '回归测试'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('粘贴的多条任务位于已有任务之前', () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.createTask({ title: '已有任务' })
|
||||||
|
|
||||||
|
paste(wrapper, '第一条\n第二条')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第一条', '第二条', '已有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('粘贴到指定列(进行中)', () => {
|
||||||
|
const { store, wrapper } = setup('doing')
|
||||||
|
|
||||||
|
paste(wrapper, 'A\nB')
|
||||||
|
|
||||||
|
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['A', 'B'])
|
||||||
|
expect(store.byColumn.todo).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('单行粘贴不拦截,交回浏览器默认行为', () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
|
||||||
|
expect(paste(wrapper, '单个任务')).toBe(false)
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有空行时不创建任务,也不拦截粘贴', () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
|
||||||
|
expect(paste(wrapper, ' \n\n')).toBe(false)
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('粘贴时忽略空行与列表符号', () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
|
||||||
|
paste(wrapper, '- 写周报\n\n- 评审 PR\n')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['写周报', '评审 PR'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('粘贴前已输入的草稿并入第一条,不丢失也不残留', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
await input.setValue('手写的一条')
|
||||||
|
|
||||||
|
paste(wrapper, '粘贴一\n粘贴二')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual([
|
||||||
|
'手写的一条',
|
||||||
|
'粘贴一',
|
||||||
|
'粘贴二',
|
||||||
|
])
|
||||||
|
|
||||||
|
// v-model 的写回要等一轮视图更新
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量粘贴后弹出提示并保持聚焦,便于继续录入', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
|
||||||
|
paste(wrapper, 'A\nB')
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
expect(toast.toasts.at(-1)?.message).toBe('已批量添加 2 个任务')
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('input').element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('误粘贴时可以整批撤销', () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
paste(wrapper, 'A\nB')
|
||||||
|
|
||||||
|
const action = useToastStore().toasts.at(-1)?.action
|
||||||
|
expect(action?.label).toBe('撤销')
|
||||||
|
action?.run()
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
import type { ColumnId } from '@/types/task'
|
import type { ColumnId } from '@/types/task'
|
||||||
|
import { parseTaskLines } from '@/utils/batch'
|
||||||
|
|
||||||
const props = defineProps<{ status: ColumnId }>()
|
const props = defineProps<{ status: ColumnId }>()
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
|
const toast = useToastStore()
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
@@ -23,6 +26,32 @@ function submit(): void {
|
|||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量录入的入口之一:粘贴多行文本时按行拆成多条任务。
|
||||||
|
*
|
||||||
|
* 单行粘贴不拦截,保持浏览器默认行为 —— 常规输入/替换不该被打断。
|
||||||
|
* 输入框里已有的草稿并入第一条,既不丢内容,也不会留下会被重复提交的残留。
|
||||||
|
*/
|
||||||
|
function onPaste(event: ClipboardEvent): void {
|
||||||
|
const pasted = parseTaskLines(event.clipboardData?.getData('text') ?? '')
|
||||||
|
if (pasted.length < 2) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const draft = title.value.trim()
|
||||||
|
const titles = draft ? [draft, ...pasted] : pasted
|
||||||
|
const created = store.createTasks(titles.map((item) => ({ title: item, status: props.status })))
|
||||||
|
if (created.length === 0) return
|
||||||
|
|
||||||
|
title.value = ''
|
||||||
|
const ids = created.map((task) => task.id)
|
||||||
|
toast.show({
|
||||||
|
message: `已批量添加 ${created.length} 个任务`,
|
||||||
|
action: { label: '撤销', run: () => store.removeTasks(ids) },
|
||||||
|
})
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
/** 供快捷键(N)调用 */
|
/** 供快捷键(N)调用 */
|
||||||
function focusInput(): void {
|
function focusInput(): void {
|
||||||
inputRef.value?.focus()
|
inputRef.value?.focus()
|
||||||
@@ -42,6 +71,7 @@ defineExpose({ focusInput })
|
|||||||
aria-label="添加任务"
|
aria-label="添加任务"
|
||||||
@compositionstart="composing = true"
|
@compositionstart="composing = true"
|
||||||
@compositionend="composing = false"
|
@compositionend="composing = false"
|
||||||
|
@paste="onPaste"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ describe('SideDrawer 焦点管理', () => {
|
|||||||
expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭')
|
expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('initialFocus 指定的元素优先获得焦点', async () => {
|
||||||
|
const wrapper = mount(SideDrawer, {
|
||||||
|
props: { open: true, initialFocus: 'textarea' },
|
||||||
|
slots: { default: '<button type="button">按钮</button><textarea aria-label="清单" />' },
|
||||||
|
global: { stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
mounted.push(wrapper)
|
||||||
|
|
||||||
|
await flush()
|
||||||
|
expect(document.activeElement?.getAttribute('aria-label')).toBe('清单')
|
||||||
|
})
|
||||||
|
|
||||||
it('关闭后焦点回到打开前的元素', async () => {
|
it('关闭后焦点回到打开前的元素', async () => {
|
||||||
const trigger = document.createElement('button')
|
const trigger = document.createElement('button')
|
||||||
trigger.textContent = '触发按钮'
|
trigger.textContent = '触发按钮'
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ open: boolean; title?: string }>()
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
/**
|
||||||
|
* 打开时优先聚焦的元素(CSS 选择器)。
|
||||||
|
* 默认聚焦面板内第一个可聚焦元素,也就是常驻在头部的「关闭」按钮;
|
||||||
|
* 需要「一打开就能输入」的面板(如批量添加)可用它把焦点放到正文控件上。
|
||||||
|
*/
|
||||||
|
initialFocus?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [] }>()
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
@@ -67,7 +76,10 @@ watch(
|
|||||||
previouslyFocused = document.activeElement as HTMLElement | null
|
previouslyFocused = document.activeElement as HTMLElement | null
|
||||||
document.addEventListener('keydown', onDocumentKeydown, true)
|
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
const target = focusableElements()[0] ?? panelRef.value
|
const preferred = props.initialFocus
|
||||||
|
? (panelRef.value?.querySelector<HTMLElement>(props.initialFocus) ?? null)
|
||||||
|
: null
|
||||||
|
const target = preferred ?? focusableElements()[0] ?? panelRef.value
|
||||||
target?.focus()
|
target?.focus()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -202,3 +202,58 @@ describe('TaskCard 键盘操作', () => {
|
|||||||
expect(event.defaultPrevented).toBe(true)
|
expect(event.defaultPrevented).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('TaskCard 多选态', () => {
|
||||||
|
function mountSelectable(selected = false) {
|
||||||
|
return mount(TaskCard, {
|
||||||
|
props: { task: makeTask({ id: 'task-9' }), selectable: true, selected },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('平时不带 checkbox 语义,多选时才加上', () => {
|
||||||
|
expect(mountCard().find('article').attributes('role')).toBeUndefined()
|
||||||
|
expect(mountSelectable().find('article').attributes('role')).toBe('checkbox')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选中态通过 aria-checked 暴露给读屏', () => {
|
||||||
|
expect(mountSelectable(false).find('article').attributes('aria-checked')).toBe('false')
|
||||||
|
expect(mountSelectable(true).find('article').attributes('aria-checked')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选中态有可辨识的高亮样式', () => {
|
||||||
|
const classes = mountSelectable(true).find('article').classes().join(' ')
|
||||||
|
expect(classes).toContain('bg-sky-50')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选时点击是「选中」而不是「打开详情」', async () => {
|
||||||
|
const wrapper = mountSelectable()
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('select')).toEqual([['task-9']])
|
||||||
|
expect(wrapper.emitted('open')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选时 Enter 与空格切换选中', async () => {
|
||||||
|
const wrapper = mountSelectable()
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('keydown', { key: 'Enter' })
|
||||||
|
await wrapper.find('article').trigger('keydown', { key: ' ' })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('select')).toEqual([['task-9'], ['task-9']])
|
||||||
|
expect(wrapper.emitted('open')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选时方向键不再移动卡片', async () => {
|
||||||
|
const wrapper = mountSelectable()
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('keydown', { key: 'ArrowRight' })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('keymove')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选时不显示「完成」勾选按钮,避免与选中勾选混淆', () => {
|
||||||
|
expect(mountCard().find('article button').exists()).toBe(true)
|
||||||
|
expect(mountSelectable().find('article button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,12 +6,18 @@ import { formatDueDate, isOverdue } from '@/utils/date'
|
|||||||
|
|
||||||
const MAX_VISIBLE_TAGS = 3
|
const MAX_VISIBLE_TAGS = 3
|
||||||
|
|
||||||
const props = defineProps<{ task: Task }>()
|
const props = defineProps<{
|
||||||
|
task: Task
|
||||||
|
/** 多选模式:卡片改为可勾选,点击是「选中」而不是「打开详情」 */
|
||||||
|
selectable?: boolean
|
||||||
|
selected?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
toggle: [id: string]
|
toggle: [id: string]
|
||||||
open: [id: string]
|
open: [id: string]
|
||||||
keymove: [payload: { id: string; direction: MoveDirection }]
|
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||||
|
select: [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 方向键映射;左右换列,上下调顺序 */
|
/** 方向键映射;左右换列,上下调顺序 */
|
||||||
@@ -22,6 +28,15 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
|
|||||||
ArrowDown: 'down',
|
ArrowDown: 'down',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 多选态下点击/回车都是「切换选中」;平时是「打开详情」 */
|
||||||
|
function activate(): void {
|
||||||
|
if (props.selectable) {
|
||||||
|
emit('select', props.task.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('open', props.task.id)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡片获得焦点后的键盘操作。
|
* 卡片获得焦点后的键盘操作。
|
||||||
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
|
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
|
||||||
@@ -29,10 +44,13 @@ const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
|
|||||||
function onKeydown(event: KeyboardEvent): void {
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
emit('open', props.task.id)
|
activate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 多选态不做方向键移动:此时拖拽与排序都已停用,避免误操作打乱顺序
|
||||||
|
if (props.selectable) return
|
||||||
|
|
||||||
const direction = MOVE_KEYS[event.key]
|
const direction = MOVE_KEYS[event.key]
|
||||||
if (!direction) return
|
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 visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
|
||||||
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - 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>
|
<template>
|
||||||
<article
|
<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="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border shadow-card transition duration-150"
|
||||||
:class="
|
:class="cardClass"
|
||||||
isDone
|
|
||||||
? 'opacity-60'
|
|
||||||
: 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:hover:border-zinc-700'
|
|
||||||
"
|
|
||||||
:data-task-id="task.id"
|
:data-task-id="task.id"
|
||||||
|
:role="selectable ? 'checkbox' : undefined"
|
||||||
|
:aria-checked="selectable ? selected === true : undefined"
|
||||||
|
:aria-label="selectable ? `选择任务「${task.title}」` : undefined"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@click="emit('open', task.id)"
|
@click="activate"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
<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="py-2 pr-2.5 pl-3.5">
|
||||||
<div class="flex items-start gap-2">
|
<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
|
<button
|
||||||
|
v-else
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
|
class="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border transition"
|
||||||
:class="
|
:class="
|
||||||
|
|||||||
@@ -114,6 +114,91 @@ describe('快速新增', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('批量创建', () => {
|
||||||
|
it('一次创建多条,整批位于列首且保持输入顺序', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '已有任务' })
|
||||||
|
|
||||||
|
const created = store.createTasks([
|
||||||
|
{ title: '第一条' },
|
||||||
|
{ title: '第二条' },
|
||||||
|
{ title: '第三条' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(titles(created)).toEqual(['第一条', '第二条', '第三条'])
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['第一条', '第二条', '第三条', '已有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跳过空标题,其余顺序不变', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const created = store.createTasks([{ title: ' ' }, { title: '有效' }, { title: '' }])
|
||||||
|
|
||||||
|
expect(titles(created)).toEqual(['有效'])
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全部为空时不产生任务,也不落盘', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const save = vi.spyOn(repository, 'save')
|
||||||
|
const store = freshStore(repository)
|
||||||
|
|
||||||
|
expect(store.createTasks([{ title: ' ' }, { title: '' }])).toEqual([])
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
expect(save).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整批只写一次仓库,而不是逐条提交', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const save = vi.spyOn(repository, 'save')
|
||||||
|
const store = freshStore(repository)
|
||||||
|
save.mockClear()
|
||||||
|
|
||||||
|
store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
|
||||||
|
expect(save).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('每条任务可以带各自的列、优先级、标签与截止日期', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const created = store.createTasks([
|
||||||
|
{ title: 'A', status: 'doing', priority: 3, tags: ['前端'], dueDate: '2026-10-20' },
|
||||||
|
{ title: 'B', status: 'done' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(created[0]?.status).toBe('doing')
|
||||||
|
expect(created[0]?.priority).toBe(3)
|
||||||
|
expect(created[0]?.tags).toEqual(['前端'])
|
||||||
|
expect(created[0]?.dueDate).toBe('2026-10-20')
|
||||||
|
expect(created[1]?.completedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨列批量时各列内部都保持输入顺序', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTasks([
|
||||||
|
{ title: '待办一' },
|
||||||
|
{ title: '进行一', status: 'doing' },
|
||||||
|
{ title: '待办二' },
|
||||||
|
{ title: '进行二', status: 'doing' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['待办一', '待办二'])
|
||||||
|
expect(titles(store.byColumn.doing)).toEqual(['进行一', '进行二'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同列 sortOrder 严格递增,不出现并列', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTasks(Array.from({ length: 30 }, (_, index) => ({ title: `任务 ${index}` })))
|
||||||
|
|
||||||
|
const orders = store.byColumn.todo.map((task) => task.sortOrder)
|
||||||
|
expect(new Set(orders).size).toBe(orders.length)
|
||||||
|
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量创建的标题首尾空白被去除', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
expect(titles(store.createTasks([{ title: ' 写方案 ' }]))).toEqual(['写方案'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('编辑', () => {
|
describe('编辑', () => {
|
||||||
it('修改标题并刷新 updatedAt', async () => {
|
it('修改标题并刷新 updatedAt', async () => {
|
||||||
const store = freshStore()
|
const store = freshStore()
|
||||||
@@ -193,6 +278,47 @@ describe('删除与撤销', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('批量删除与撤销', () => {
|
||||||
|
it('一次删除多条并返回被删任务(保持原顺序)', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const created = store.createTasks([{ title: 'A' }, { title: 'B' }, { title: 'C' }])
|
||||||
|
const doomed = created.filter((task) => task.title !== 'B').map((task) => task.id)
|
||||||
|
|
||||||
|
const removed = store.removeTasks(doomed)
|
||||||
|
|
||||||
|
expect(titles(removed)).toEqual(['A', 'C'])
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('整批撤销后连原顺序一起还原', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '既有' })
|
||||||
|
const created = store.createTasks([{ title: 'A' }, { title: 'B' }])
|
||||||
|
|
||||||
|
const removed = store.removeTasks(created.map((task) => task.id))
|
||||||
|
store.restoreTasks(removed)
|
||||||
|
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['A', 'B', '既有'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('id 全部不存在时不做任何变更', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
|
||||||
|
expect(store.removeTasks(['不存在'])).toEqual([])
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已存在的任务不会被重复恢复', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const created = store.createTasks([{ title: 'A' }])
|
||||||
|
|
||||||
|
store.restoreTasks(created)
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('拖拽移动', () => {
|
describe('拖拽移动', () => {
|
||||||
it('跨列拖动更新 status 与 completedAt', () => {
|
it('跨列拖动更新 status 与 completedAt', () => {
|
||||||
const store = freshStore()
|
const store = freshStore()
|
||||||
|
|||||||
+63
-8
@@ -243,12 +243,32 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
if (options.crossTab !== false) setupChannel()
|
if (options.crossTab !== false) setupChannel()
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTask(input: CreateTaskInput): Task | null {
|
/**
|
||||||
const title = input.title.trim()
|
* 批量创建:一次落盘 + 一次广播,而不是逐条创建时的 N 次写入。
|
||||||
if (!title) return null
|
*
|
||||||
|
* 位置约定与单条创建一致 —— 新任务插入列首;同时**同一批内保持输入顺序**,
|
||||||
|
* 即第 1 行在最上面。做法是从后往前逐条插到列首:中点插入本身就保证相邻
|
||||||
|
* sortOrder 严格递增,因此不必为整批单独设计排序值。
|
||||||
|
*
|
||||||
|
* @returns 实际创建的任务,顺序与入参一致;空标题(含纯空白)会被跳过
|
||||||
|
*/
|
||||||
|
function createTasks(inputs: readonly CreateTaskInput[]): Task[] {
|
||||||
|
const drafts = inputs
|
||||||
|
.map((input) => ({ input, title: input.title.trim() }))
|
||||||
|
.filter((draft) => draft.title.length > 0)
|
||||||
|
if (drafts.length === 0) return []
|
||||||
|
|
||||||
const status = input.status ?? 'todo'
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
/** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */
|
||||||
|
const working = groupByColumn(tasks.value)
|
||||||
|
const created: Task[] = []
|
||||||
|
|
||||||
|
for (let index = drafts.length - 1; index >= 0; index -= 1) {
|
||||||
|
const draft = drafts[index]
|
||||||
|
if (!draft) continue
|
||||||
|
const { input, title } = draft
|
||||||
|
const status = input.status ?? 'todo'
|
||||||
|
|
||||||
const task: Task = {
|
const task: Task = {
|
||||||
id: createId(),
|
id: createId(),
|
||||||
title,
|
title,
|
||||||
@@ -257,17 +277,25 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
priority: input.priority ?? DEFAULT_PRIORITY,
|
priority: input.priority ?? DEFAULT_PRIORITY,
|
||||||
tags: normalizeTags(input.tags ?? []),
|
tags: normalizeTags(input.tags ?? []),
|
||||||
dueDate: input.dueDate ?? null,
|
dueDate: input.dueDate ?? null,
|
||||||
// 快速新增插入列首
|
sortOrder: sortOrderForPosition(working[status], 0),
|
||||||
sortOrder: sortOrderForPosition(groupByColumn(tasks.value)[status], 0),
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
completedAt: status === 'done' ? now : null,
|
completedAt: status === 'done' ? now : null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.value = [...tasks.value, task]
|
working[status] = [task, ...working[status]]
|
||||||
|
created.unshift(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.value = [...tasks.value, ...created]
|
||||||
commit()
|
commit()
|
||||||
return task
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条创建:批量创建的特例,语义与插入位置完全一致 */
|
||||||
|
function createTask(input: CreateTaskInput): Task | null {
|
||||||
|
return createTasks([input])[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTask(id: string, patch: TaskPatch): Task | null {
|
function updateTask(id: string, patch: TaskPatch): Task | null {
|
||||||
@@ -330,6 +358,30 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
commit()
|
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 []
|
||||||
|
|
||||||
|
tasks.value = tasks.value.filter((task) => !idSet.has(task.id))
|
||||||
|
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
|
||||||
|
|
||||||
|
tasks.value = [...tasks.value, ...restored]
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
|
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
|
||||||
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
|
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
|
||||||
@@ -444,9 +496,12 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
allTags,
|
allTags,
|
||||||
init,
|
init,
|
||||||
createTask,
|
createTask,
|
||||||
|
createTasks,
|
||||||
updateTask,
|
updateTask,
|
||||||
removeTask,
|
removeTask,
|
||||||
restoreTask,
|
restoreTask,
|
||||||
|
removeTasks,
|
||||||
|
restoreTasks,
|
||||||
moveTask,
|
moveTask,
|
||||||
exportJson,
|
exportJson,
|
||||||
importJson,
|
importJson,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { parseTaskLines } from './batch'
|
||||||
|
|
||||||
|
describe('parseTaskLines', () => {
|
||||||
|
it('单行文本返回单元素数组', () => {
|
||||||
|
expect(parseTaskLines('写周报')).toEqual(['写周报'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按行拆分成多条', () => {
|
||||||
|
expect(parseTaskLines('写周报\n评审 PR\n回归测试')).toEqual(['写周报', '评审 PR', '回归测试'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('兼容 CRLF 换行', () => {
|
||||||
|
expect(parseTaskLines('第一条\r\n第二条')).toEqual(['第一条', '第二条'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('忽略空行与纯空白行', () => {
|
||||||
|
expect(parseTaskLines('第一条\n\n \n第二条\n')).toEqual(['第一条', '第二条'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('去掉每行首尾空白', () => {
|
||||||
|
expect(parseTaskLines(' 写周报 \n\t评审 PR')).toEqual(['写周报', '评审 PR'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('去掉 Markdown 列表前缀', () => {
|
||||||
|
expect(parseTaskLines('- 写周报\n* 评审 PR\n+ 回归测试\n• 发版')).toEqual([
|
||||||
|
'写周报',
|
||||||
|
'评审 PR',
|
||||||
|
'回归测试',
|
||||||
|
'发版',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('去掉有序列表前缀', () => {
|
||||||
|
expect(parseTaskLines('1. 写周报\n2) 评审 PR\n10. 发版')).toEqual(['写周报', '评审 PR', '发版'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('前缀后没有空格时不当作列表符号', () => {
|
||||||
|
expect(parseTaskLines('a-b 联调\n-5℃ 环境复核')).toEqual(['a-b 联调', '-5℃ 环境复核'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有列表符号的行被丢弃', () => {
|
||||||
|
expect(parseTaskLines('- \n- 写周报')).toEqual(['写周报'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空字符串返回空数组', () => {
|
||||||
|
expect(parseTaskLines('')).toEqual([])
|
||||||
|
expect(parseTaskLines(' \n\n')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保留重复标题(同名任务可能是两件事)', () => {
|
||||||
|
expect(parseTaskLines('评审\n评审')).toEqual(['评审', '评审'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 批量录入的文本解析
|
||||||
|
*
|
||||||
|
* 约定「一行一条任务」,便于从笔记、Markdown 清单、聊天记录里整段粘贴。
|
||||||
|
* 纯函数、无副作用,便于单独测试。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 常见列表前缀:`- ` / `* ` / `+ ` / `• ` 以及 `1. ` / `1) `。
|
||||||
|
* 只在前缀后确实跟着空格时才去掉,因此 `a-b`、`-5℃ 复核` 这类标题不受影响。
|
||||||
|
*/
|
||||||
|
const LIST_PREFIX = /^\s*(?:[-*+•]|\d+[.)])\s+/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一段文本按行拆成任务标题列表。
|
||||||
|
*
|
||||||
|
* - 兼容 `\n` 与 `\r\n`
|
||||||
|
* - 去掉每行首尾空白与列表前缀
|
||||||
|
* - 丢弃空行与纯空白行(粘贴时常带上多余换行)
|
||||||
|
*/
|
||||||
|
export function parseTaskLines(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.replace(LIST_PREFIX, '').trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
}
|
||||||
@@ -565,3 +565,218 @@ describe('BoardView 键盘移动', () => {
|
|||||||
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BoardView 批量添加', () => {
|
||||||
|
function buttonByText(wrapper: ReturnType<typeof mountBoard>, text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('点击工具栏「批量添加」打开面板', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(false)
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '批量添加')?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('textarea[aria-label="批量任务清单"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量添加后卡片出现在看板上', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '批量添加')?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('textarea[aria-label="批量任务清单"]').setValue('写周报\n评审 PR')
|
||||||
|
await buttonByText(wrapper, '添加 2 个任务')?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['写周报', '评审 PR'])
|
||||||
|
expect(wrapper.text()).toContain('写周报')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 多选与批量删除', () => {
|
||||||
|
/** 文案精确匹配:「多选」是「退出多选」的子串 */
|
||||||
|
function exactButton(wrapper: ReturnType<typeof mountBoard>, text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterSelection(wrapper: ReturnType<typeof mountBoard>) {
|
||||||
|
await exactButton(wrapper, '多选')?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('点击「多选」后卡片变为可勾选,并出现操作条', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('已选')
|
||||||
|
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已选 0 个任务')
|
||||||
|
expect(wrapper.find('article').attributes('role')).toBe('checkbox')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击卡片即选中,计数同步', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTasks([{ title: 'A' }, { title: 'B' }])
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
await wrapper.find('[data-task-id]').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已选 1 个任务')
|
||||||
|
expect(wrapper.find('article').attributes('aria-checked')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多选时点击卡片不会打开详情抽屉', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('全选选中当前可见的全部任务', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTasks([{ title: 'A' }, { title: 'B', status: 'doing' }, { title: 'C' }])
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((node) => node.text().includes('全选'))
|
||||||
|
?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已选 3 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('筛选后全选只覆盖可见任务', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTasks([
|
||||||
|
{ title: '高优先', priority: 3 },
|
||||||
|
{ title: '普通', priority: 1 },
|
||||||
|
])
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((node) => node.text().includes('全选'))
|
||||||
|
?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已选 1 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除选中任务后弹出可撤销的提示,撤销连原顺序一起还原', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTasks([{ title: 'A' }, { title: 'B' }])
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
await wrapper.find('[data-task-id]').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((node) => node.text().includes('删除'))
|
||||||
|
?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.tasks.map((item) => item.title)).toEqual(['B'])
|
||||||
|
// 删除后自动退出多选
|
||||||
|
expect(wrapper.text()).not.toContain('已选')
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
expect(toast.toasts.at(-1)?.message).toBe('已删除 1 个任务')
|
||||||
|
toast.toasts.at(-1)?.action?.run()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有选中任务时删除按钮不可用', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
const removeButton = wrapper.findAll('button').find((node) => node.text().includes('删除 0 个'))
|
||||||
|
expect(removeButton?.attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('筛选条件变化会清空选择,避免删掉看不见的任务', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTasks([
|
||||||
|
{ title: 'A', priority: 3 },
|
||||||
|
{ title: 'B', priority: 1 },
|
||||||
|
])
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
await wrapper.find('[data-task-id]').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.text()).toContain('已选 1 个任务')
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(1)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已选 0 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('退出多选后清空选择并恢复卡片的常规语义', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await exactButton(wrapper, '退出多选')?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('已选')
|
||||||
|
expect(wrapper.find('article').attributes('role')).toBeUndefined()
|
||||||
|
|
||||||
|
// 恢复后点击卡片重新等于「打开详情」
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('[role="dialog"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档后清空选择,避免选中已不可见的卡片', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: '很久前完成', status: 'done' })
|
||||||
|
store.updateTask(task?.id ?? '', { completedAt: dayjs().subtract(30, 'day').toISOString() })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await enterSelection(wrapper)
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((node) => node.text().includes('全选'))
|
||||||
|
?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.text()).toContain('已选 1 个任务')
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((node) => node.text().includes('个已完成'))
|
||||||
|
?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已选 0 个任务')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+89
-2
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
import BoardColumn from '@/components/BoardColumn.vue'
|
import BoardColumn from '@/components/BoardColumn.vue'
|
||||||
import BoardToolbar from '@/components/BoardToolbar.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 TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
||||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
import { useFilterStore } from '@/stores/filters'
|
import { useFilterStore } from '@/stores/filters'
|
||||||
@@ -25,6 +27,13 @@ const { filter, includeArchived } = storeToRefs(filters)
|
|||||||
const editingId = ref<string | null>(null)
|
const editingId = ref<string | null>(null)
|
||||||
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? 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 toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
|
||||||
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
|
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
|
||||||
|
|
||||||
@@ -95,6 +104,9 @@ function archiveCompleted(): void {
|
|||||||
const count = store.archiveTasks(ids)
|
const count = store.archiveTasks(ids)
|
||||||
if (count === 0) return
|
if (count === 0) return
|
||||||
|
|
||||||
|
// 归档后这些卡片默认不可见,留在选择里会让「已选」与眼前看到的对不上
|
||||||
|
if (selecting.value) clearSelection()
|
||||||
|
|
||||||
toast.show({
|
toast.show({
|
||||||
message: `已归档 ${count} 个已完成任务`,
|
message: `已归档 ${count} 个已完成任务`,
|
||||||
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
|
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
|
||||||
@@ -104,7 +116,7 @@ function archiveCompleted(): void {
|
|||||||
/** 有面板打开时不响应全局快捷键,避免误触 */
|
/** 有面板打开时不响应全局快捷键,避免误触 */
|
||||||
function whenNoPanel(action: () => void): () => void {
|
function whenNoPanel(action: () => void): () => void {
|
||||||
return () => {
|
return () => {
|
||||||
if (editingId.value !== null || ui.anyPanelOpen) return
|
if (editingId.value !== null || batchOpen.value || selecting.value || ui.anyPanelOpen) return
|
||||||
action()
|
action()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +174,65 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
|
|||||||
document.querySelector<HTMLElement>(`[data-task-id="${payload.id}"]`)?.focus()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -169,9 +240,21 @@ function handleKeyMove(payload: { id: string; direction: MoveDirection }): void
|
|||||||
<BoardToolbar
|
<BoardToolbar
|
||||||
ref="toolbarRef"
|
ref="toolbarRef"
|
||||||
:hidden-note="hiddenNote"
|
:hidden-note="hiddenNote"
|
||||||
|
:selecting="selecting"
|
||||||
@archive="archiveCompleted"
|
@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"
|
:column="column"
|
||||||
:tasks="visibleColumns[column.id]"
|
:tasks="visibleColumns[column.id]"
|
||||||
:sort-disabled="hiddenCounts[column.id] > 0"
|
:sort-disabled="hiddenCounts[column.id] > 0"
|
||||||
|
:selectable="selecting"
|
||||||
|
:selected-ids="selectedIds"
|
||||||
@toggle="toggleTask"
|
@toggle="toggleTask"
|
||||||
@open="openTask"
|
@open="openTask"
|
||||||
@move="handleMove"
|
@move="handleMove"
|
||||||
@keymove="handleKeyMove"
|
@keymove="handleKeyMove"
|
||||||
|
@select="toggleSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
|
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
|
||||||
|
<BatchAddDrawer :open="batchOpen" @close="batchOpen = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user