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,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()
|
||||
})
|
||||
Reference in New Issue
Block a user