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
+91
View File
@@ -0,0 +1,91 @@
import { writeFile } from 'node:fs/promises'
import { expect, test } from '@playwright/test'
import { addTask, openBackupPanel } from './helpers'
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('导出 → 清空 → 导入,数据完全还原', async ({ page }, testInfo) => {
await addTask(page, '备份任务甲')
await addTask(page, '备份任务乙')
await expect(page.locator('article')).toHaveCount(2)
// 导出
await openBackupPanel(page)
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: '导出为 JSON' }).click(),
])
expect(download.suggestedFilename()).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
const exported = testInfo.outputPath('export.json')
await download.saveAs(exported)
// 模拟数据丢失
await page.evaluate(() => localStorage.removeItem('kanban.tasks'))
await page.reload()
await expect(page.locator('article')).toHaveCount(0)
// 导入
await openBackupPanel(page)
await page.setInputFiles('input[type="file"]', exported)
await expect(page.getByText('覆盖当前全部 0 个任务')).toBeVisible()
await page.getByRole('button', { name: '确认覆盖导入' }).click()
await expect(page.locator('article')).toHaveCount(2)
await expect(page.locator('article').filter({ hasText: '备份任务甲' })).toBeVisible()
await expect(page.locator('article').filter({ hasText: '备份任务乙' })).toBeVisible()
await page.reload()
await expect(page.locator('article')).toHaveCount(2)
})
test('导入非法文件时给出错误且不破坏现有数据', async ({ page }, testInfo) => {
await addTask(page, '不能丢的任务')
const brokenFile = testInfo.outputPath('broken.json')
await writeFile(brokenFile, '{ this is not json', 'utf8')
await openBackupPanel(page)
await page.setInputFiles('input[type="file"]', brokenFile)
await expect(page.getByText('不是合法的 JSON')).toBeVisible()
await expect(page.locator('article')).toHaveCount(1)
await expect(page.locator('article').filter({ hasText: '不能丢的任务' })).toBeVisible()
})
test('导入结构不合法时同样拒绝并保留现有数据', async ({ page }, testInfo) => {
await addTask(page, '保留我')
const badShape = testInfo.outputPath('bad-shape.json')
await writeFile(badShape, JSON.stringify({ tasks: [{ id: 'broken' }] }), 'utf8')
await openBackupPanel(page)
await page.setInputFiles('input[type="file"]', badShape)
await expect(page.getByText('第 1 条任务格式不合法')).toBeVisible()
await expect(page.getByRole('button', { name: '确认覆盖导入' })).toHaveCount(0)
await expect(page.locator('article')).toHaveCount(1)
})
test('自动备份列表可恢复数据', async ({ page }) => {
await addTask(page, '会被快照记录的任务')
// 重新打开看板会写入一份启动快照
await page.reload()
await expect(page.locator('article')).toHaveCount(1)
// 清空数据后,用启动快照恢复
await page.evaluate(() => localStorage.removeItem('kanban.tasks'))
await page.reload()
await expect(page.locator('article')).toHaveCount(0)
await openBackupPanel(page)
await page.getByRole('button', { name: '恢复' }).first().click()
await expect(page.getByText(/已从备份恢复 \d+ 个任务/)).toBeVisible()
await expect(page.locator('article').filter({ hasText: '会被快照记录的任务' })).toBeVisible()
})