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
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { COLUMNS, COLUMN_IDS, PRIORITY_LABELS, isColumnId, isPriority } from './task'
describe('列定义', () => {
it('固定三列,顺序为 待办 / 进行中 / 已完成', () => {
expect(COLUMNS.map((column) => column.id)).toEqual(['todo', 'doing', 'done'])
})
it('COLUMN_IDS 与 COLUMNS 完全一致', () => {
expect([...COLUMN_IDS]).toEqual(COLUMNS.map((column) => column.id))
})
it('列定义为只读常量,不包含重复 id', () => {
expect(new Set(COLUMN_IDS).size).toBe(COLUMN_IDS.length)
})
})
describe('优先级', () => {
it('三个优先级都有中文标签', () => {
expect(PRIORITY_LABELS[1]).toBe('低')
expect(PRIORITY_LABELS[2]).toBe('中')
expect(PRIORITY_LABELS[3]).toBe('高')
})
it('isPriority 只接受 1/2/3', () => {
expect(isPriority(1)).toBe(true)
expect(isPriority(3)).toBe(true)
expect(isPriority(0)).toBe(false)
expect(isPriority(4)).toBe(false)
expect(isPriority('2')).toBe(false)
expect(isPriority(null)).toBe(false)
})
})
describe('isColumnId', () => {
it('接受合法列 id', () => {
expect(isColumnId('todo')).toBe(true)
expect(isColumnId('done')).toBe(true)
})
it('拒绝非法值', () => {
expect(isColumnId('archived')).toBe(false)
expect(isColumnId(undefined)).toBe(false)
expect(isColumnId(1)).toBe(false)
})
})
+63
View File
@@ -0,0 +1,63 @@
/**
* 领域类型与常量(T03)
*
* 设计约定(见 docs/adr.md):
* - 列固定为三列,不使用可配置列,因此 status 直接用字面量联合类型而非 enum
* - dueDate 仅表示「日期」,格式 YYYY-MM-DD,不含时刻,以此规避时区问题
*/
export const COLUMN_IDS = ['todo', 'doing', 'done'] as const
export type ColumnId = (typeof COLUMN_IDS)[number]
/** 优先级:1 = 低,2 = 中,3 = 高 */
export type Priority = 1 | 2 | 3
export interface Task {
id: string
title: string
description: string
status: ColumnId
priority: Priority
tags: string[]
/** 仅日期,格式 YYYY-MM-DD;无截止日期时为 null */
dueDate: string | null
/** 同列内排序用的浮点数,采用相邻中点插入 */
sortOrder: number
createdAt: string
updatedAt: string
completedAt: string | null
/** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */
archivedAt: string | null
}
export interface ColumnDef {
readonly id: ColumnId
readonly title: string
}
/** 看板列定义,顺序即展示顺序 */
export const COLUMNS: readonly ColumnDef[] = [
{ id: 'todo', title: '待办' },
{ id: 'doing', title: '进行中' },
{ id: 'done', title: '已完成' },
]
export const PRIORITY_LABELS: Readonly<Record<Priority, string>> = {
1: '低',
2: '中',
3: '高',
}
/** 新建任务时的默认值 */
export const DEFAULT_PRIORITY: Priority = 2
/** 判断某个值是否为合法的列 id */
export function isColumnId(value: unknown): value is ColumnId {
return typeof value === 'string' && (COLUMN_IDS as readonly string[]).includes(value)
}
/** 判断某个值是否为合法的优先级 */
export function isPriority(value: unknown): value is Priority {
return value === 1 || value === 2 || value === 3
}