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
+261
View File
@@ -0,0 +1,261 @@
import { flushPromises, mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTaskStore } from '@/stores/tasks'
import BackupPanel from './BackupPanel.vue'
vi.mock('@/utils/exportFile', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/exportFile')>()
return { ...actual, downloadText: vi.fn() }
})
const { downloadText } = await import('@/utils/exportFile')
function makePayload(titles: string[]): string {
return JSON.stringify({
schemaVersion: 1,
tasks: titles.map((title, index) => ({
id: `imported-${index}`,
title,
status: 'todo',
priority: 2,
sortOrder: (index + 1) * 1024,
})),
})
}
async function setup(open = true) {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
const repository = createMemoryTaskRepository()
store.init({ repository, crossTab: false })
store.createTask({ title: '原有任务' })
const wrapper = mount(BackupPanel, {
props: { open },
global: { plugins: [pinia], stubs: { teleport: true } },
attachTo: document.body,
})
return { store, wrapper }
}
async function chooseFile(wrapper: VueWrapper, content: string, fileName = 'backup.json') {
const input = wrapper.find('input[type="file"]')
const file = new File([content], fileName, { type: 'application/json' })
// jsdom 的 input.files 是只读的,需要直接定义
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
await flushPromises()
}
function buttonByText(wrapper: VueWrapper, text: string) {
return wrapper.findAll('button').find((node) => node.text().includes(text))
}
describe('BackupPanel 显示', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('open 为 false 时不渲染内容', () => {
const wrapper = mount(BackupPanel, {
props: { open: false },
global: { stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('导出为 JSON')
})
it('展示数据风险提示', async () => {
const { wrapper } = await setup()
expect(wrapper.text()).toContain('清理浏览器数据会导致任务丢失')
})
})
describe('BackupPanel 导出', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('点击导出会生成带日期的文件名并写入内容', async () => {
const { store, wrapper } = await setup()
await buttonByText(wrapper, '导出为 JSON')?.trigger('click')
expect(downloadText).toHaveBeenCalledTimes(1)
const [content, fileName] = vi.mocked(downloadText).mock.calls[0] ?? []
expect(fileName).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
expect(content).toContain('原有任务')
expect(wrapper.text()).toContain(`已导出 ${store.tasks.length} 个任务`)
})
})
describe('BackupPanel 导入', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('选择合法文件后先请求确认,不立即改动数据', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
expect(wrapper.text()).toContain('导入 2 个任务')
expect(wrapper.text()).toContain('覆盖当前全部 1 个任务')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('确认后覆盖导入', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
await buttonByText(wrapper, '确认覆盖导入')?.trigger('click')
expect(store.tasks.map((task) => task.title).sort()).toEqual(['新任务一', '新任务二'])
expect(wrapper.text()).toContain('已从 backup.json 导入 2 个任务')
})
it('取消确认后数据保持不变', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, makePayload(['新任务']))
await buttonByText(wrapper, '取消')?.trigger('click')
expect(wrapper.text()).not.toContain('确认覆盖导入')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('非法 JSON 给出明确错误且不破坏现有数据', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, '{broken json')
expect(wrapper.text()).toContain('不是合法的 JSON')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('结构不合法时给出错误且不进入确认流程', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, JSON.stringify({ tasks: [{ id: 'broken' }] }))
expect(wrapper.text()).toContain('第 1 条任务格式不合法')
expect(wrapper.text()).not.toContain('确认覆盖导入')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
it('版本过高时拒绝导入', async () => {
const { store, wrapper } = await setup()
await chooseFile(wrapper, JSON.stringify({ schemaVersion: 99, tasks: [] }))
expect(wrapper.text()).toContain('高于当前支持')
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
})
})
describe('BackupPanel 自动备份', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(downloadText).mockClear()
})
it('启动时写入的快照会出现在列表中', async () => {
const repository = createMemoryTaskRepository()
// 第一次启动:看板为空,不产生快照
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '已有任务' })
// 第二次启动:此时已有数据,写入启动快照
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository, crossTab: false })
expect(store.snapshots).toHaveLength(1)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('暂无自动备份')
expect(buttonByText(wrapper, '恢复')).toBeDefined()
})
it('空看板不写快照,显示暂无备份', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).toContain('暂无自动备份')
})
it('看板为空时仍会列出已有快照(数据丢失后要能恢复)', async () => {
const repository = createMemoryTaskRepository()
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '快照里的任务' })
// 第二次启动:写入快照
setActivePinia(createPinia())
const second = useTaskStore()
second.init({ repository, crossTab: false })
expect(second.snapshots).toHaveLength(1)
// 清空数据后第三次启动:不能因为看板为空就丢掉快照列表
second.replaceAll([])
const pinia = createPinia()
setActivePinia(pinia)
const third = useTaskStore()
third.init({ repository, crossTab: false })
expect(third.tasks).toHaveLength(0)
expect(third.snapshots).toHaveLength(1)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
expect(wrapper.text()).not.toContain('暂无自动备份')
expect(buttonByText(wrapper, '恢复')).toBeDefined()
})
it('点击恢复会用快照内容覆盖当前数据', async () => {
const repository = createMemoryTaskRepository()
setActivePinia(createPinia())
const first = useTaskStore()
first.init({ repository, crossTab: false })
first.createTask({ title: '快照里的任务' })
const pinia = createPinia()
setActivePinia(pinia)
const store = useTaskStore()
store.init({ repository, crossTab: false })
expect(store.snapshots).toHaveLength(1)
// 模拟数据丢失
store.replaceAll([])
expect(store.tasks).toHaveLength(0)
const wrapper = mount(BackupPanel, {
props: { open: true },
global: { plugins: [pinia], stubs: { teleport: true } },
})
await buttonByText(wrapper, '恢复')?.trigger('click')
expect(store.tasks.map((task) => task.title)).toEqual(['快照里的任务'])
expect(wrapper.text()).toContain('已从备份恢复 1 个任务')
})
})