feat: 个人轻量看板工具 MVP
从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。 功能 - 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序 - 快速新增、详情编辑、删除撤销、已完成归档 - 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期) - 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片 - 导出导入 JSON;启动时自动留快照,保留最近 3 份 - 键盘快捷键(N / 「/」/ ? / Esc) - PWA:可安装、离线可用、更新提示 技术要点 - 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表 显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt) - 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽 - 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据 - 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据 - 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005) 验证 - 单元测试 437 个(33 个文件),行覆盖率 96.8% - 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景 - npm run build 通过,产物可静态托管 注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useDraggable } from 'vue-draggable-plus'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import type { ColumnDef, Task } from '@/types/task'
|
||||
import { resolveDrop } from '@/utils/drag'
|
||||
|
||||
import BoardColumn from './BoardColumn.vue'
|
||||
|
||||
/**
|
||||
* 屏蔽真实的 Sortable:jsdom 没有布局,真实实例在测试中不起作用,
|
||||
* 而且我们要断言的是「拖拽事件 → emit」这段接线,直接调用库回调更可靠。
|
||||
*/
|
||||
vi.mock('vue-draggable-plus', () => ({ useDraggable: vi.fn() }))
|
||||
|
||||
interface DragOptions {
|
||||
onEnd?: (event: unknown) => void
|
||||
onMove?: (event: unknown, originalEvent: unknown) => unknown
|
||||
}
|
||||
|
||||
/** 取最近一次传给 useDraggable 的配置 */
|
||||
function lastDragOptions(): DragOptions {
|
||||
const calls = vi.mocked(useDraggable).mock.calls
|
||||
return (calls[calls.length - 1]?.[1] ?? {}) as DragOptions
|
||||
}
|
||||
|
||||
const TODO_COLUMN: ColumnDef = { id: 'todo', title: '待办' }
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: 'task-1',
|
||||
title: '写方案',
|
||||
description: '',
|
||||
status: 'todo',
|
||||
priority: 2,
|
||||
tags: [],
|
||||
dueDate: null,
|
||||
sortOrder: 1024,
|
||||
createdAt: '2026-10-18T00:00:00.000Z',
|
||||
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||
completedAt: null,
|
||||
archivedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setup(tasks: Task[] = [makeTask()], sortDisabled = false) {
|
||||
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, sortDisabled },
|
||||
global: { plugins: [pinia] },
|
||||
})
|
||||
return { store, wrapper }
|
||||
}
|
||||
|
||||
describe('BoardColumn 渲染', () => {
|
||||
it('渲染列标题与计数', () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'a' }), makeTask({ id: 'b' })])
|
||||
expect(wrapper.find('h2').text()).toBe('待办')
|
||||
expect(wrapper.find('header span').text()).toBe('2')
|
||||
})
|
||||
|
||||
it('空列显示空状态', () => {
|
||||
const { wrapper } = setup([])
|
||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('暂无任务')
|
||||
})
|
||||
|
||||
it('列内包含快速新增输入框', () => {
|
||||
const { wrapper } = setup([])
|
||||
expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardColumn 拖拽契约', () => {
|
||||
it('拖拽容器带 data-column-id', () => {
|
||||
const { wrapper } = setup()
|
||||
expect(wrapper.find('[data-column-id="todo"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('卡片带 data-task-id', () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'task-xyz' })])
|
||||
expect(wrapper.find('[data-task-id="task-xyz"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('渲染出的真实 DOM 能满足 resolveDrop 的解析契约', () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })])
|
||||
|
||||
const list = wrapper.find('[data-column-id="todo"]').element as HTMLElement
|
||||
const item = wrapper.find('[data-task-id="task-2"]').element as HTMLElement
|
||||
|
||||
expect(resolveDrop({ item, to: list, newIndex: 0 })).toEqual({
|
||||
taskId: 'task-2',
|
||||
toColumn: 'todo',
|
||||
toIndex: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardColumn 交互', () => {
|
||||
it('点击完成按钮 emit toggle', async () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||
await wrapper.find('article button').trigger('click')
|
||||
expect(wrapper.emitted('toggle')).toEqual([['task-1']])
|
||||
})
|
||||
|
||||
it('点击卡片 emit open', async () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||
await wrapper.find('article').trigger('click')
|
||||
expect(wrapper.emitted('open')).toEqual([['task-1']])
|
||||
})
|
||||
|
||||
it('通过界面新增任务会写入 store', async () => {
|
||||
const { store, wrapper } = setup([])
|
||||
await wrapper.find('input[aria-label="添加任务"]').setValue('新任务')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['新任务'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardColumn 长列表', () => {
|
||||
function makeMany(count: number): Task[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
makeTask({ id: `task-${index}`, title: `任务 ${index}`, sortOrder: (index + 1) * 1024 }),
|
||||
)
|
||||
}
|
||||
|
||||
it('单列 200 张卡片全部渲染且计数正确', () => {
|
||||
const { wrapper } = setup(makeMany(200))
|
||||
|
||||
expect(wrapper.findAll('article')).toHaveLength(200)
|
||||
expect(wrapper.find('header span').text()).toBe('200')
|
||||
})
|
||||
|
||||
it('200 张卡片仍保持 sortOrder 升序渲染', () => {
|
||||
const { wrapper } = setup(makeMany(200))
|
||||
const titles = wrapper.findAll('article h3').map((node) => node.text())
|
||||
|
||||
expect(titles[0]).toBe('任务 0')
|
||||
expect(titles[199]).toBe('任务 199')
|
||||
})
|
||||
|
||||
it('200 张卡片时仍能通过界面新增任务', async () => {
|
||||
const { store, wrapper } = setup(makeMany(200))
|
||||
|
||||
await wrapper.find('input[aria-label="添加任务"]').setValue('插到最前')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
expect(store.byColumn.todo).toHaveLength(201)
|
||||
expect(store.byColumn.todo[0]?.title).toBe('插到最前')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BoardColumn 拖拽事件接线', () => {
|
||||
it('onEnd 解析落点并 emit move', () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||
const item = wrapper.find('[data-task-id="task-1"]').element
|
||||
const to = wrapper.find('[data-column-id="todo"]').element
|
||||
|
||||
lastDragOptions().onEnd?.({ item, to, newIndex: 0 })
|
||||
|
||||
expect(wrapper.emitted('move')).toEqual([[{ id: 'task-1', toColumn: 'todo', toIndex: 0 }]])
|
||||
})
|
||||
|
||||
it('onEnd 缺少任务信息时不 emit', () => {
|
||||
const { wrapper } = setup()
|
||||
const to = wrapper.find('[data-column-id="todo"]').element
|
||||
|
||||
lastDragOptions().onEnd?.({ item: document.createElement('article'), to, newIndex: 0 })
|
||||
|
||||
expect(wrapper.emitted('move')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('onEnd 下标缺失时不 emit', () => {
|
||||
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||
const item = wrapper.find('[data-task-id="task-1"]').element
|
||||
const to = wrapper.find('[data-column-id="todo"]').element
|
||||
|
||||
lastDragOptions().onEnd?.({ item, to })
|
||||
|
||||
expect(wrapper.emitted('move')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('列内为全量渲染时 onMove 一律允许', () => {
|
||||
const { wrapper } = setup([makeTask()], false)
|
||||
const same = wrapper.find('[data-column-id="todo"]').element
|
||||
|
||||
const result = lastDragOptions().onMove?.({ from: same, to: same }, new Event('move'))
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('sortDisabled 时 onMove 禁止列内排序但允许跨列', () => {
|
||||
const { wrapper } = setup([makeTask()], true)
|
||||
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(true)
|
||||
})
|
||||
|
||||
it('传给 useDraggable 的分组与动画配置正确', () => {
|
||||
setup()
|
||||
const calls = vi.mocked(useDraggable).mock.calls
|
||||
const options = calls[calls.length - 1]?.[1] as Record<string, unknown>
|
||||
|
||||
expect(options.group).toBe('kanban-board')
|
||||
expect(options.filter).toBe('button')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user