feat(board): 支持批量添加与批量删除任务
- 批量添加面板:一行一条,整批共用目标列、优先级、标签与截止日期 - 快速新增支持粘贴多行文本,直接拆成多条任务 - 多选模式支持全选可见任务、整批删除,撤销时连原顺序一起还原 - 批量创建与删除走 store 的批量方法,一次落盘、一次广播 - 补充单元测试与 E2E 用例
This commit is contained in:
@@ -92,6 +92,66 @@ test('编辑任务时可以直接选已有标签', async ({ page }) => {
|
||||
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 }) => {
|
||||
await addTask(page, '待删除任务')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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 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<{
|
||||
toggle: [id: string]
|
||||
open: [id: string]
|
||||
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
||||
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||
select: [id: string]
|
||||
}>()
|
||||
|
||||
/** 每列一个色点:三列在余光里也能区分开 */
|
||||
@@ -46,7 +54,9 @@ useDraggable(listRef, {
|
||||
ghostClass: 'opacity-40',
|
||||
// 在完成勾选按钮上按下时不启动拖拽
|
||||
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) => {
|
||||
const drop = resolveDrop(event)
|
||||
if (!drop) return
|
||||
@@ -81,9 +91,12 @@ useDraggable(listRef, {
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:selectable="selectable === true"
|
||||
:selected="selectedIds?.has(task.id) ?? false"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@open="emit('open', $event)"
|
||||
@keymove="emit('keymove', $event)"
|
||||
@select="emit('select', $event)"
|
||||
/>
|
||||
|
||||
<p
|
||||
|
||||
@@ -188,3 +188,46 @@ describe('BoardToolbar 归档', () => {
|
||||
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'
|
||||
|
||||
defineProps<{ hiddenNote?: string }>()
|
||||
defineProps<{ hiddenNote?: string; selecting?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ archive: [] }>()
|
||||
const emit = defineEmits<{ archive: []; 'batch-add': []; 'toggle-select': [] }>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const filters = useFilterStore()
|
||||
@@ -133,6 +133,28 @@ defineExpose({ focusSearch })
|
||||
<span v-if="hiddenNote" class="text-slate-400 dark:text-zinc-500">{{ hiddenNote }}</span>
|
||||
|
||||
<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
|
||||
v-if="archivableCount > 0"
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ColumnId } from '@/types/task'
|
||||
|
||||
import QuickAddTask from './QuickAddTask.vue'
|
||||
@@ -98,3 +99,104 @@ describe('QuickAddTask', () => {
|
||||
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 { useTaskStore } from '@/stores/tasks'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ColumnId } from '@/types/task'
|
||||
import { parseTaskLines } from '@/utils/batch'
|
||||
|
||||
const props = defineProps<{ status: ColumnId }>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const toast = useToastStore()
|
||||
const title = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -23,6 +26,32 @@ function submit(): void {
|
||||
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)调用 */
|
||||
function focusInput(): void {
|
||||
inputRef.value?.focus()
|
||||
@@ -42,6 +71,7 @@ defineExpose({ focusInput })
|
||||
aria-label="添加任务"
|
||||
@compositionstart="composing = true"
|
||||
@compositionend="composing = false"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
@@ -127,6 +127,19 @@ describe('SideDrawer 焦点管理', () => {
|
||||
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 () => {
|
||||
const trigger = document.createElement('button')
|
||||
trigger.textContent = '触发按钮'
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
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: [] }>()
|
||||
|
||||
@@ -67,7 +76,10 @@ watch(
|
||||
previouslyFocused = document.activeElement as HTMLElement | null
|
||||
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||
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()
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -202,3 +202,58 @@ describe('TaskCard 键盘操作', () => {
|
||||
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 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="
|
||||
|
||||
@@ -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('编辑', () => {
|
||||
it('修改标题并刷新 updatedAt', async () => {
|
||||
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('拖拽移动', () => {
|
||||
it('跨列拖动更新 status 与 completedAt', () => {
|
||||
const store = freshStore()
|
||||
|
||||
+75
-20
@@ -243,31 +243,59 @@ export const useTaskStore = defineStore('tasks', () => {
|
||||
if (options.crossTab !== false) setupChannel()
|
||||
}
|
||||
|
||||
function createTask(input: CreateTaskInput): Task | null {
|
||||
const title = input.title.trim()
|
||||
if (!title) return null
|
||||
/**
|
||||
* 批量创建:一次落盘 + 一次广播,而不是逐条创建时的 N 次写入。
|
||||
*
|
||||
* 位置约定与单条创建一致 —— 新任务插入列首;同时**同一批内保持输入顺序**,
|
||||
* 即第 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 task: Task = {
|
||||
id: createId(),
|
||||
title,
|
||||
description: input.description?.trim() ?? '',
|
||||
status,
|
||||
priority: input.priority ?? DEFAULT_PRIORITY,
|
||||
tags: normalizeTags(input.tags ?? []),
|
||||
dueDate: input.dueDate ?? null,
|
||||
// 快速新增插入列首
|
||||
sortOrder: sortOrderForPosition(groupByColumn(tasks.value)[status], 0),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: status === 'done' ? now : null,
|
||||
archivedAt: null,
|
||||
/** 同列工作副本:含本批已创建的任务,供中点插入取前后邻居 */
|
||||
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 = {
|
||||
id: createId(),
|
||||
title,
|
||||
description: input.description?.trim() ?? '',
|
||||
status,
|
||||
priority: input.priority ?? DEFAULT_PRIORITY,
|
||||
tags: normalizeTags(input.tags ?? []),
|
||||
dueDate: input.dueDate ?? null,
|
||||
sortOrder: sortOrderForPosition(working[status], 0),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: status === 'done' ? now : null,
|
||||
archivedAt: null,
|
||||
}
|
||||
|
||||
working[status] = [task, ...working[status]]
|
||||
created.unshift(task)
|
||||
}
|
||||
|
||||
tasks.value = [...tasks.value, task]
|
||||
tasks.value = [...tasks.value, ...created]
|
||||
commit()
|
||||
return task
|
||||
return created
|
||||
}
|
||||
|
||||
/** 单条创建:批量创建的特例,语义与插入位置完全一致 */
|
||||
function createTask(input: CreateTaskInput): Task | null {
|
||||
return createTasks([input])[0] ?? null
|
||||
}
|
||||
|
||||
function updateTask(id: string, patch: TaskPatch): Task | null {
|
||||
@@ -330,6 +358,30 @@ export const useTaskStore = defineStore('tasks', () => {
|
||||
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 为移动完成后在目标列中的最终下标。
|
||||
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
|
||||
@@ -444,9 +496,12 @@ export const useTaskStore = defineStore('tasks', () => {
|
||||
allTags,
|
||||
init,
|
||||
createTask,
|
||||
createTasks,
|
||||
updateTask,
|
||||
removeTask,
|
||||
restoreTask,
|
||||
removeTasks,
|
||||
restoreTasks,
|
||||
moveTask,
|
||||
exportJson,
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
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">
|
||||
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