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
+450
View File
@@ -0,0 +1,450 @@
import { enableAutoUnmount, mount } from '@vue/test-utils'
import dayjs from 'dayjs'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { afterEach, describe, expect, it } from 'vitest'
import BoardColumn from '@/components/BoardColumn.vue'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import BoardView from './BoardView.vue'
// BoardView 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰
enableAutoUnmount(afterEach)
function setup() {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
return { pinia, store }
}
function mountBoard(pinia: ReturnType<typeof createPinia>) {
// 抽屉内容会被 Teleport 到 body,测试中改为就地渲染以便断言
return mount(BoardView, {
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
}
describe('BoardView 结构', () => {
it('渲染三个固定列', () => {
const { pinia } = setup()
const headings = mountBoard(pinia)
.findAll('h2')
.map((node) => node.text())
expect(headings).toEqual(['待办', '进行中', '已完成'])
})
it('空看板显示空状态', () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('暂无任务')
})
it('每列显示任务计数', () => {
const { pinia, store } = setup()
store.createTask({ title: '待办事项' })
store.createTask({ title: '进行中事项', status: 'doing' })
const counters = mountBoard(pinia)
.findAll('section header span')
.map((node) => node.text())
expect(counters).toEqual(['1', '1', '0'])
})
it('渲染任务标题并显示总数', () => {
const { pinia, store } = setup()
store.createTask({ title: '写方案' })
store.createTask({ title: '做原型', status: 'doing' })
const wrapper = mountBoard(pinia)
expect(wrapper.text()).toContain('写方案')
expect(wrapper.text()).toContain('做原型')
expect(wrapper.text()).toContain('共 2 个任务')
})
it('任务按列归位', () => {
const { pinia, store } = setup()
store.createTask({ title: '待办事项' })
store.createTask({ title: '进行中事项', status: 'doing' })
store.createTask({ title: '已完成事项', status: 'done' })
const sections = mountBoard(pinia).findAll('section')
expect(sections[0]?.text()).toContain('待办事项')
expect(sections[0]?.text()).not.toContain('进行中事项')
expect(sections[1]?.text()).toContain('进行中事项')
expect(sections[2]?.text()).toContain('已完成事项')
})
it('存在存储提示时展示提示条', () => {
const { pinia, store } = setup()
store.notice = '本地存储不可用'
expect(mountBoard(pinia).text()).toContain('本地存储不可用')
})
})
describe('BoardView 端到端交互', () => {
it('在界面上输入并回车即可新增任务', async () => {
const { pinia, store } = setup()
const wrapper = mountBoard(pinia)
const firstColumnInput = wrapper.findAll('section')[0]?.find('input')
expect(firstColumnInput).toBeDefined()
await firstColumnInput?.setValue('界面新增的任务')
await wrapper.findAll('form')[0]?.trigger('submit')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['界面新增的任务'])
expect(wrapper.text()).toContain('界面新增的任务')
})
it('点击完成按钮后卡片移动到已完成列', async () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article button').trigger('click')
expect(store.byColumn.todo).toHaveLength(0)
expect(store.byColumn.done.map((task) => task.title)).toEqual(['待办任务'])
expect(wrapper.findAll('section')[2]?.text()).toContain('待办任务')
})
it('再次点击可撤销完成', async () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article button').trigger('click')
await wrapper.find('article button').trigger('click')
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['待办任务'])
expect(store.byColumn.done).toHaveLength(0)
})
})
describe('BoardView 详情抽屉', () => {
it('初始不渲染详情表单', () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
expect(mountBoard(pinia).find('input[aria-label="标题"]').exists()).toBe(false)
})
it('点击卡片打开抽屉并回填标题', async () => {
const { pinia, store } = setup()
store.createTask({ title: '待办任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
'待办任务',
)
})
it('保存后看板同步更新', async () => {
const { pinia, store } = setup()
store.createTask({ title: '旧标题' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
await wrapper.find('input[aria-label="标题"]').setValue('新标题')
const saveButton = wrapper.findAll('button').find((node) => node.text() === '保存')
await saveButton?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('新标题')
expect(store.tasks[0]?.title).toBe('新标题')
})
it('取消后抽屉关闭且数据不变', async () => {
const { pinia, store } = setup()
store.createTask({ title: '原标题' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
const cancelButton = wrapper.findAll('button').find((node) => node.text() === '取消')
await cancelButton?.trigger('click')
await nextTick()
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
expect(store.tasks[0]?.title).toBe('原标题')
})
it('删除后抽屉关闭且任务从看板消失', async () => {
const { pinia, store } = setup()
store.createTask({ title: '要删除的任务' })
const wrapper = mountBoard(pinia)
await wrapper.find('article').trigger('click')
await nextTick()
const deleteButton = wrapper.findAll('button').find((node) => node.text() === '删除')
await deleteButton?.trigger('click')
await nextTick()
expect(store.tasks).toHaveLength(0)
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
})
})
describe('BoardView 拖拽接线', () => {
it('列组件 emit move 时驱动 store 改变状态', async () => {
const { pinia, store } = setup()
const task = store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
const column = wrapper.findAllComponents(BoardColumn)[0]
column?.vm.$emit('move', { id: task?.id ?? '', toColumn: 'doing', toIndex: 0 })
await nextTick()
expect(store.byColumn.todo).toHaveLength(0)
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
})
it('列内移动事件驱动顺序变化', async () => {
const { pinia, store } = setup()
const a = store.createTask({ title: 'A' })
store.createTask({ title: 'B' })
store.createTask({ title: 'C' })
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'B', 'A'])
const wrapper = mountBoard(pinia)
const column = wrapper.findAllComponents(BoardColumn)[0]
column?.vm.$emit('move', { id: a?.id ?? '', toColumn: 'todo', toIndex: 0 })
await nextTick()
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'C', 'B'])
expect(wrapper.findAll('section')[0]?.text()).toContain('A')
})
})
describe('BoardView 筛选', () => {
it('按优先级筛选后只显示匹配卡片,列计数同步变化', async () => {
const { pinia, store } = setup()
store.createTask({ title: '高优先级', priority: 3 })
store.createTask({ title: '低优先级', priority: 1 })
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(2)
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(1)
expect(wrapper.text()).toContain('高优先级')
expect(wrapper.text()).not.toContain('低优先级')
expect(wrapper.findAll('section header span')[0]?.text()).toBe('1')
})
it('显示命中数量提示', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A', priority: 3 })
store.createTask({ title: 'B', priority: 1 })
const wrapper = mountBoard(pinia)
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.text()).toContain('显示 1 / 2 个任务')
})
it('清空筛选后恢复全部卡片', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A', priority: 3 })
store.createTask({ title: 'B', priority: 1 })
const wrapper = mountBoard(pinia)
const filters = useFilterStore()
filters.togglePriority(3)
await nextTick()
filters.clear()
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(2)
expect(wrapper.text()).not.toContain('显示 1 / 2')
})
it('点击「逾期」只显示逾期任务', async () => {
const { pinia, store } = setup()
store.createTask({
title: '逾期任务',
dueDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
})
store.createTask({ title: '未来任务', dueDate: dayjs().add(3, 'day').format('YYYY-MM-DD') })
const wrapper = mountBoard(pinia)
const overdueButton = wrapper.findAll('button').find((node) => node.text().includes('逾期'))
await overdueButton?.trigger('click')
await nextTick()
expect(wrapper.text()).toContain('逾期任务')
expect(wrapper.text()).not.toContain('未来任务')
})
it('列内渲染子集时给列组件传 sortDisabled(拖拽保护)', async () => {
const { pinia, store } = setup()
store.createTask({ title: 'A' })
const wrapper = mountBoard(pinia)
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
useFilterStore().togglePriority(3)
await nextTick()
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(true)
})
})
describe('BoardView 归档', () => {
function archiveButton(wrapper: ReturnType<typeof mountBoard>) {
return wrapper.findAll('button').find((node) => node.text().includes('个已完成'))
}
async function createArchivable(store: ReturnType<typeof useTaskStore>) {
const task = store.createTask({ title: '很久前完成的任务', status: 'done' })
store.updateTask(task?.id ?? '', {
completedAt: dayjs().subtract(30, 'day').toISOString(),
})
return task
}
it('默认不显示归档任务', async () => {
const { pinia, store } = setup()
const task = await createArchivable(store)
store.archiveTasks([task?.id ?? ''])
const wrapper = mountBoard(pinia)
expect(wrapper.findAll('article')).toHaveLength(0)
expect(wrapper.text()).toContain('显示 0 / 1 个任务')
})
it('开启「显示归档」后归档任务重新出现', async () => {
const { pinia, store } = setup()
const task = await createArchivable(store)
store.archiveTasks([task?.id ?? ''])
const wrapper = mountBoard(pinia)
const toggle = wrapper.findAll('button').find((node) => node.text() === '显示归档')
await toggle?.trigger('click')
await nextTick()
expect(wrapper.findAll('article')).toHaveLength(1)
expect(wrapper.text()).toContain('很久前完成的任务')
})
it('归档后给出可撤销提示,撤销后任务回到看板', async () => {
const { pinia, store } = setup()
await createArchivable(store)
const wrapper = mountBoard(pinia)
await archiveButton(wrapper)?.trigger('click')
await nextTick()
expect(store.tasks[0]?.archivedAt).not.toBeNull()
expect(wrapper.findAll('article')).toHaveLength(0)
const toast = useToastStore()
expect(toast.toasts[0]?.action?.label).toBe('撤销')
toast.runAction(toast.toasts[0]?.id ?? '')
await nextTick()
expect(store.tasks[0]?.archivedAt).toBeNull()
expect(wrapper.findAll('article')).toHaveLength(1)
})
it('隐藏归档时「已完成」列禁用列内排序', async () => {
const { pinia, store } = setup()
const task = await createArchivable(store)
store.archiveTasks([task?.id ?? ''])
const wrapper = mountBoard(pinia)
// 归档任务只可能在「已完成」列
expect(wrapper.findAllComponents(BoardColumn)[2]?.props('sortDisabled')).toBe(true)
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
})
})
describe('BoardView 快捷键', () => {
function pressKey(key: string, target: EventTarget = document): void {
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
}
it('按 N 聚焦待办列的快速新增输入框', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('n')
expect(document.activeElement).toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('按 / 聚焦搜索框', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('/')
expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element)
})
it('按 ? 打开快捷键帮助', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
pressKey('?')
await nextTick()
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
})
it('在输入框内按 N 不触发快捷键', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
const search = wrapper.find('input[aria-label="搜索任务"]')
pressKey('n', search.element)
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('面板打开时不响应快捷键', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
await nextTick()
pressKey('n')
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
})
it('按 Esc 关闭已打开的面板', async () => {
const { pinia } = setup()
const wrapper = mountBoard(pinia)
await nextTick()
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
await nextTick()
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
pressKey('Escape')
await nextTick()
expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框')
})
})