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
+409
View File
@@ -0,0 +1,409 @@
/**
* 任务 Store(T06)
*
* 职责边界:
* - Store 是内存态的唯一来源,所有读写都经由 TaskRepository
* - 每次变更后 persist + broadcast,保证刷新不丢、多标签页一致
* - 纯计算(排序、分组)委托给 @/utils/sortOrder,便于单独测试
*/
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { parseImportJson, serializeState } from '@/data/schema'
import {
createLocalStorageTaskRepository,
createMemoryTaskRepository,
type SnapshotInfo,
type TaskRepository,
} from '@/data/taskRepository'
import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
import { createId } from '@/utils/id'
import { groupByColumn, moveWithinColumn, sortByOrder, sortOrderForPosition } from '@/utils/sortOrder'
/** 跨标签页同步使用的频道名 */
export const SYNC_CHANNEL_NAME = 'kanban.sync'
export interface CreateTaskInput {
title: string
description?: string
priority?: Priority
tags?: string[]
dueDate?: string | null
status?: ColumnId
}
export type TaskPatch = Partial<Omit<Task, 'id' | 'createdAt'>>
export interface InitOptions {
/** 注入仓库;不传则使用 localStorage,不可用时降级为内存仓库 */
repository?: TaskRepository
/** 是否启用跨标签页同步,测试中应关闭 */
crossTab?: boolean
}
interface SyncMessage {
type: 'tasks-changed'
}
function isSyncMessage(value: unknown): value is SyncMessage {
return (
typeof value === 'object' &&
value !== null &&
(value as { type?: unknown }).type === 'tasks-changed'
)
}
function normalizeTags(tags: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const tag of tags) {
const value = tag.trim()
if (!value || seen.has(value)) continue
seen.add(value)
result.push(value)
}
return result
}
export const useTaskStore = defineStore('tasks', () => {
const tasks = ref<Task[]>([])
const ready = ref(false)
/** 数据是否真正落盘;false 表示当前处于内存降级模式 */
const persistent = ref(false)
/** 需要向用户展示的存储异常提示 */
const notice = ref<string | null>(null)
/** 可用的启动快照,按时间倒序 */
const snapshots = ref<SnapshotInfo[]>([])
let repository: TaskRepository | null = null
let channel: BroadcastChannel | null = null
const byColumn = computed(() => groupByColumn(tasks.value))
const counts = computed(() => {
const result = {} as Record<ColumnId, number>
for (const columnId of Object.keys(byColumn.value) as ColumnId[]) {
result[columnId] = byColumn.value[columnId].length
}
return result
})
function persist(): void {
if (!repository) return
try {
repository.save(tasks.value)
} catch (error) {
notice.value = error instanceof Error ? error.message : '保存失败'
}
}
function broadcastChange(): void {
channel?.postMessage({ type: 'tasks-changed' } satisfies SyncMessage)
}
function commit(): void {
persist()
broadcastChange()
}
function reloadFromRepository(): void {
if (!repository) return
try {
tasks.value = sortByOrder(repository.load())
} catch {
// 另一标签页可能正在写入,读取失败时保留当前内存态即可
}
}
function refreshSnapshots(): void {
if (!repository) return
try {
snapshots.value = repository.snapshots()
} catch {
snapshots.value = []
}
}
/**
* 启动时留一份快照。
*
* 注意:即使看板为空也要刷新快照列表 —— 数据刚丢失时正是最需要
* 看到历史备份的时刻,此时若因为「空看板不写快照」而连带跳过刷新,
* 用户就彻底没有恢复入口了。
*/
function writeStartupSnapshot(): void {
if (!repository) return
if (persistent.value && tasks.value.length > 0) {
try {
repository.snapshot(tasks.value)
} catch {
// 备份失败不应影响正常使用
}
}
refreshSnapshots()
}
/** 从快照恢复;返回实际恢复的任务数 */
function restoreSnapshot(key: string): number {
if (!repository) return 0
const restored = repository.restoreSnapshot(key)
if (restored.length === 0) return 0
tasks.value = sortByOrder(restored)
commit()
return restored.length
}
function setupChannel(): void {
if (channel || typeof BroadcastChannel === 'undefined') return
channel = new BroadcastChannel(SYNC_CHANNEL_NAME)
channel.addEventListener('message', (event: MessageEvent) => {
if (isSyncMessage(event.data)) reloadFromRepository()
})
}
function init(options: InitOptions = {}): void {
if (ready.value) return
if (options.repository) {
repository = options.repository
persistent.value = true
} else {
try {
repository = createLocalStorageTaskRepository()
persistent.value = true
} catch (error) {
repository = createMemoryTaskRepository()
persistent.value = false
notice.value =
error instanceof Error ? error.message : '本地存储不可用,本次改动不会被保存'
}
}
try {
tasks.value = sortByOrder(repository.load())
} catch (error) {
tasks.value = []
notice.value = error instanceof Error ? error.message : '本地数据读取失败'
}
ready.value = true
writeStartupSnapshot()
if (options.crossTab !== false) setupChannel()
}
function createTask(input: CreateTaskInput): Task | null {
const title = input.title.trim()
if (!title) return null
const status = input.status ?? 'todo'
const now = new Date().toISOString()
const task: Task = {
id: createId(),
title,
description: input.description?.trim() ?? '',
status,
priority: input.priority ?? DEFAULT_PRIORITY,
tags: normalizeTags(input.tags ?? []),
dueDate: input.dueDate ?? null,
// 快速新增插入列首
sortOrder: sortOrderForPosition(groupByColumn(tasks.value)[status], 0),
createdAt: now,
updatedAt: now,
completedAt: status === 'done' ? now : null,
archivedAt: null,
}
tasks.value = [...tasks.value, task]
commit()
return task
}
function updateTask(id: string, patch: TaskPatch): Task | null {
const index = tasks.value.findIndex((task) => task.id === id)
const current = tasks.value[index]
if (!current) return null
const now = new Date().toISOString()
const nextStatus = patch.status ?? current.status
let completedAt = current.completedAt
if (patch.completedAt !== undefined) {
completedAt = patch.completedAt
} else if (nextStatus === 'done' && current.status !== 'done') {
completedAt = now
} else if (nextStatus !== 'done' && current.status === 'done') {
completedAt = null
}
// 状态变更且未显式指定顺序时,追加到目标列末尾
let sortOrder = patch.sortOrder ?? current.sortOrder
if (nextStatus !== current.status && patch.sortOrder === undefined) {
const target = groupByColumn(tasks.value)[nextStatus].filter((task) => task.id !== id)
sortOrder = sortOrderForPosition(target, target.length)
}
const title = patch.title === undefined ? current.title : patch.title.trim() || current.title
const updated: Task = {
...current,
...patch,
title,
status: nextStatus,
sortOrder,
tags: patch.tags ? normalizeTags(patch.tags) : current.tags,
completedAt,
updatedAt: now,
}
const next = [...tasks.value]
next[index] = updated
tasks.value = next
commit()
return updated
}
/** 删除任务,返回被删除的任务本身,供撤销使用(T12) */
function removeTask(id: string): Task | null {
const task = tasks.value.find((item) => item.id === id)
if (!task) return null
tasks.value = tasks.value.filter((item) => item.id !== id)
commit()
return task
}
/** 撤销删除:连原 sortOrder 一起恢复,因此位置也会还原 */
function restoreTask(task: Task): void {
if (tasks.value.some((item) => item.id === task.id)) return
tasks.value = [...tasks.value, task]
commit()
}
/**
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
*/
function moveTask(id: string, toStatus: ColumnId, toIndex: number): void {
const task = tasks.value.find((item) => item.id === id)
if (!task) return
const now = new Date().toISOString()
if (task.status === toStatus) {
const column = groupByColumn(tasks.value)[toStatus]
const fromIndex = column.findIndex((item) => item.id === id)
if (fromIndex === -1) return
const moved = moveWithinColumn(column, fromIndex, toIndex)
const byId = new Map(moved.map((item) => [item.id, item]))
tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item)
} else {
const target = groupByColumn(tasks.value)[toStatus].filter((item) => item.id !== id)
const sortOrder = sortOrderForPosition(target, toIndex)
tasks.value = tasks.value.map((item) =>
item.id === id
? {
...item,
status: toStatus,
sortOrder,
completedAt: toStatus === 'done' ? now : null,
updatedAt: now,
}
: item,
)
}
commit()
}
/** 导出为可读 JSON(T14) */
function exportJson(): string {
return serializeState(tasks.value, true)
}
/**
* 覆盖式导入(T14)。校验失败会抛错,此时现有数据保持不变。
* @returns 成功导入的任务数量
*/
function importJson(raw: string): number {
const imported = parseImportJson(raw)
tasks.value = sortByOrder(imported)
commit()
return imported.length
}
/** 覆盖全部数据(供导入后的自定义处理或测试使用) */
function replaceAll(next: readonly Task[]): void {
tasks.value = sortByOrder(next)
commit()
}
/**
* 归档指定任务(T17)。返回实际归档的数量。
* 归档只改 archivedAt,数据完整保留,可随时撤销。
*/
function archiveTasks(ids: readonly string[]): number {
const idSet = new Set(ids)
const now = new Date().toISOString()
let count = 0
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.archivedAt !== null) return task
count += 1
return { ...task, archivedAt: now, updatedAt: now }
})
if (count > 0) commit()
return count
}
/** 取消归档;返回实际恢复的数量 */
function unarchiveTasks(ids: readonly string[]): number {
const idSet = new Set(ids)
const now = new Date().toISOString()
let count = 0
tasks.value = tasks.value.map((task) => {
if (!idSet.has(task.id) || task.archivedAt === null) return task
count += 1
return { ...task, archivedAt: null, updatedAt: now }
})
if (count > 0) commit()
return count
}
/** 释放跨标签页频道;主要供测试使用 */
function dispose(): void {
channel?.close()
channel = null
repository = null
}
return {
tasks,
ready,
persistent,
notice,
snapshots,
byColumn,
counts,
init,
createTask,
updateTask,
removeTask,
restoreTask,
moveTask,
exportJson,
importJson,
replaceAll,
archiveTasks,
unarchiveTasks,
refreshSnapshots,
restoreSnapshot,
dispose,
}
})