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