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:
chenshoulin
2026-10-18 11:59:49 +08:00
commit 4e064e82ea
98 changed files with 20084 additions and 0 deletions
+217
View File
@@ -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')
})
})