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,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 个任务')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user