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
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, it } from 'vitest'
import {
DataValidationError,
SCHEMA_VERSION,
coerceTask,
coerceTaskList,
isValidDateString,
parseImportJson,
parsePersistedState,
serializeState,
validateImportPayload,
} from './schema'
function validTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'task-1',
title: '写方案',
description: '细节',
status: 'todo',
priority: 2,
tags: ['设计'],
dueDate: '2026-10-20',
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
describe('isValidDateString', () => {
it('接受合法日期', () => {
expect(isValidDateString('2026-10-18')).toBe(true)
expect(isValidDateString('2024-02-29')).toBe(true)
})
it('拒绝格式不对的值', () => {
expect(isValidDateString('2026-1-1')).toBe(false)
expect(isValidDateString('2026/10/18')).toBe(false)
expect(isValidDateString('')).toBe(false)
expect(isValidDateString(20261018)).toBe(false)
expect(isValidDateString(null)).toBe(false)
})
it('拒绝「格式正确但日期不存在」的值', () => {
expect(isValidDateString('2026-02-31')).toBe(false)
expect(isValidDateString('2026-13-01')).toBe(false)
expect(isValidDateString('2025-02-29')).toBe(false)
})
})
describe('coerceTask', () => {
it('接受完整合法任务', () => {
expect(coerceTask(validTask())).toEqual(validTask())
})
it('缺少 id 或 title 时返回 null', () => {
expect(coerceTask(validTask({ id: undefined }))).toBeNull()
expect(coerceTask(validTask({ title: ' ' }))).toBeNull()
expect(coerceTask(validTask({ title: 123 }))).toBeNull()
})
it('status 或 priority 非法时返回 null', () => {
expect(coerceTask(validTask({ status: 'archived' }))).toBeNull()
expect(coerceTask(validTask({ priority: 9 }))).toBeNull()
expect(coerceTask(validTask({ priority: '2' }))).toBeNull()
})
it('非对象一律返回 null', () => {
expect(coerceTask(null)).toBeNull()
expect(coerceTask('task')).toBeNull()
expect(coerceTask([])).toBeNull()
})
it('可选项缺失时补默认值', () => {
const result = coerceTask({
id: 'a',
title: '只有必填项',
status: 'doing',
priority: 1,
})
expect(result).not.toBeNull()
expect(result?.description).toBe('')
expect(result?.tags).toEqual([])
expect(result?.dueDate).toBeNull()
expect(result?.sortOrder).toBe(0)
expect(result?.completedAt).toBeNull()
expect(result?.createdAt).toBeTruthy()
})
it('非法 dueDate 收敛为 null,非法 sortOrder 收敛为 0', () => {
const result = coerceTask(validTask({ dueDate: '2026-02-31', sortOrder: 'abc' }))
expect(result?.dueDate).toBeNull()
expect(result?.sortOrder).toBe(0)
})
it('tags 过滤掉非字符串与空白项', () => {
const result = coerceTask(validTask({ tags: ['ok', 1, null, ' ', 'ok2'] }))
expect(result?.tags).toEqual(['ok', 'ok2'])
})
it('非法 archivedAt 收敛为 null', () => {
expect(coerceTask(validTask({ archivedAt: 123 }))?.archivedAt).toBeNull()
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
})
it('保留合法的 archivedAt', () => {
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
'2026-10-01T00:00:00.000Z',
)
})
})
describe('coerceTaskList', () => {
it('跳过损坏条目但保留合法条目', () => {
const list = coerceTaskList([validTask(), { id: 'broken' }, validTask({ id: 'task-2' })])
expect(list.map((task) => task.id)).toEqual(['task-1', 'task-2'])
})
it('非数组返回空数组', () => {
expect(coerceTaskList(null)).toEqual([])
expect(coerceTaskList({})).toEqual([])
})
})
describe('parsePersistedState', () => {
it('空值返回空看板', () => {
expect(parsePersistedState(null)).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
expect(parsePersistedState(' ')).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
})
it('非 JSON 抛 DataValidationError', () => {
expect(() => parsePersistedState('{oops')).toThrow(DataValidationError)
})
it('非对象抛 DataValidationError', () => {
expect(() => parsePersistedState('[1,2,3]')).toThrow(DataValidationError)
})
it('缺少 schemaVersion 的旧数据迁移为当前版本', () => {
const legacy = JSON.stringify({ tasks: [validTask()] })
const state = parsePersistedState(legacy)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks).toHaveLength(1)
})
it('版本高于当前支持时抛错(不静默降级)', () => {
const future = JSON.stringify({ schemaVersion: SCHEMA_VERSION + 1, tasks: [] })
expect(() => parsePersistedState(future)).toThrow(/高于当前支持/)
})
it('损坏条目被跳过,其余数据仍可用', () => {
const mixed = JSON.stringify({
schemaVersion: SCHEMA_VERSION,
tasks: [validTask(), { id: 'x', title: '' }],
})
expect(parsePersistedState(mixed).tasks).toHaveLength(1)
})
})
describe('版本迁移', () => {
it('v1 数据迁移到 v2 时补上 archivedAt,其余字段不变', () => {
const v1 = JSON.stringify({
schemaVersion: 1,
tasks: [
{
id: 'legacy',
title: '旧任务',
status: 'done',
priority: 3,
sortOrder: 1024,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
completedAt: '2026-01-02T00:00:00.000Z',
},
],
})
const state = parsePersistedState(v1)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.archivedAt).toBeNull()
expect(state.tasks[0]?.title).toBe('旧任务')
expect(state.tasks[0]?.priority).toBe(3)
expect(state.tasks[0]?.completedAt).toBe('2026-01-02T00:00:00.000Z')
})
it('无版本号的最早期数据能一路迁移到当前版本', () => {
const legacy = JSON.stringify({
tasks: [{ id: 'a', title: '最早的任务', status: 'todo', priority: 2 }],
})
const state = parsePersistedState(legacy)
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
expect(state.tasks[0]?.archivedAt).toBeNull()
})
it('归档状态在序列化往返后保留', () => {
const tasks = coerceTaskList([validTask({ archivedAt: '2026-10-01T00:00:00.000Z' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks[0]?.archivedAt).toBe('2026-10-01T00:00:00.000Z')
})
it('多版本迁移对多条任务都生效', () => {
const v1 = JSON.stringify({
schemaVersion: 1,
tasks: [
{ id: 'a', title: 'A', status: 'todo', priority: 1 },
{ id: 'b', title: 'B', status: 'done', priority: 2 },
],
})
const state = parsePersistedState(v1)
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
})
})
describe('序列化往返', () => {
it('serializeState 与 parsePersistedState 互逆', () => {
const tasks = coerceTaskList([validTask(), validTask({ id: 'task-2', status: 'done' })])
const roundTrip = parsePersistedState(serializeState(tasks))
expect(roundTrip.tasks).toEqual(tasks)
expect(roundTrip.schemaVersion).toBe(SCHEMA_VERSION)
})
it('pretty 模式仍可解析', () => {
const tasks = coerceTaskList([validTask()])
const pretty = serializeState(tasks, true)
expect(pretty).toContain('\n')
expect(parsePersistedState(pretty).tasks).toEqual(tasks)
})
})
describe('导入校验(严格)', () => {
it('任何一条任务不合法即整体拒绝', () => {
expect(() => validateImportPayload({ tasks: [validTask(), { id: 'broken' }] })).toThrow(
/第 2 条任务格式不合法/,
)
})
it('缺少 tasks 数组时抛错', () => {
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
})
it('非对象时抛错', () => {
expect(() => validateImportPayload([])).toThrow(/必须是一个 JSON 对象/)
})
it('版本过高时拒绝导入', () => {
expect(() => validateImportPayload({ schemaVersion: 99, tasks: [] })).toThrow(/高于当前支持/)
})
it('允许省略 schemaVersion', () => {
expect(validateImportPayload({ tasks: [validTask()] })).toHaveLength(1)
})
it('parseImportJson 对非法 JSON 给出明确错误', () => {
expect(() => parseImportJson('not json')).toThrow(/不是合法的 JSON/)
})
})
+221
View File
@@ -0,0 +1,221 @@
/**
* 持久化数据结构与迁移(T04)
*
* 职责:
* - 定义落盘结构 PersistedState(含 schemaVersion)
* - 解析与迁移:parsePersistedState(宽容,用于读取本地数据)
* - 导入校验:parseImportJson(严格,用于导入外部文件)
*/
import { isColumnId, isPriority, type Task } from '@/types/task'
export const SCHEMA_VERSION = 2
/** 主数据在 localStorage 中的键名 */
export const STORAGE_KEY = 'kanban.tasks'
/** 自动快照键前缀(T14) */
export const SNAPSHOT_KEY_PREFIX = 'kanban.snapshot.'
export interface PersistedState {
schemaVersion: number
tasks: Task[]
}
/** 数据不符合预期结构时抛出,调用方据此提示用户而不是白屏 */
export class DataValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'DataValidationError'
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
/** 校验 YYYY-MM-DD,并排除 2026-02-31 这类「格式对但日期不存在」的值 */
export function isValidDateString(value: unknown): value is string {
if (typeof value !== 'string' || !DATE_PATTERN.test(value)) return false
const parsed = new Date(`${value}T00:00:00.000Z`)
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' ? value : null
}
/**
* 将未知值收敛为合法 Task;无法收敛时返回 null。
* 单个任务损坏不应导致整份数据不可用,因此这里采用「跳过」而非「抛错」。
*/
export function coerceTask(value: unknown): Task | null {
if (!isRecord(value)) return null
const id = readString(value, 'id')
const title = readString(value, 'title')
if (!id || !title || title.trim() === '') return null
const status = value.status
if (!isColumnId(status)) return null
const priority = value.priority
if (!isPriority(priority)) return null
const rawTags = Array.isArray(value.tags) ? value.tags : []
const tags = rawTags.filter(
(tag): tag is string => typeof tag === 'string' && tag.trim() !== '',
)
const dueDate = value.dueDate
const sortOrder = value.sortOrder
const createdAt = readString(value, 'createdAt') ?? new Date().toISOString()
const updatedAt = readString(value, 'updatedAt') ?? createdAt
const completedAt = readString(value, 'completedAt')
const archivedAt = readString(value, 'archivedAt')
return {
id,
title,
description: readString(value, 'description') ?? '',
status,
priority,
tags,
dueDate: isValidDateString(dueDate) ? dueDate : null,
sortOrder: typeof sortOrder === 'number' && Number.isFinite(sortOrder) ? sortOrder : 0,
createdAt,
updatedAt,
completedAt,
archivedAt,
}
}
export function coerceTaskList(value: unknown): Task[] {
if (!Array.isArray(value)) return []
const tasks: Task[] = []
for (const item of value) {
const task = coerceTask(item)
if (task) tasks.push(task)
}
return tasks
}
type Migration = (state: PersistedState) => PersistedState
/**
* 版本迁移表,键为「迁移前的版本号」。
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
* - v1 -> v2:新增 archivedAt 字段(归档功能)
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
*/
const MIGRATIONS: Record<number, Migration> = {
0: (state) => ({ ...state, schemaVersion: 1 }),
1: (state) => ({
schemaVersion: 2,
tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })),
}),
}
/**
* 解析本地持久化数据(宽容策略)。
* 空值返回空看板;结构性错误抛出 DataValidationError。
*/
export function parsePersistedState(raw: string | null): PersistedState {
if (raw === null || raw.trim() === '') {
return { schemaVersion: SCHEMA_VERSION, tasks: [] }
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new DataValidationError('本地数据不是合法的 JSON')
}
if (!isRecord(parsed)) {
throw new DataValidationError('本地数据结构异常:期望一个对象')
}
const rawVersion = parsed.schemaVersion
const startVersion =
typeof rawVersion === 'number' && Number.isInteger(rawVersion) && rawVersion >= 0
? rawVersion
: 0
if (startVersion > SCHEMA_VERSION) {
throw new DataValidationError(
`数据版本 ${startVersion} 高于当前支持的 ${SCHEMA_VERSION},请升级应用后再打开`,
)
}
let state: PersistedState = {
schemaVersion: startVersion,
tasks: coerceTaskList(parsed.tasks),
}
while (state.schemaVersion < SCHEMA_VERSION) {
const migration = MIGRATIONS[state.schemaVersion]
if (!migration) {
throw new DataValidationError(
`缺少从版本 ${state.schemaVersion} 到 ${state.schemaVersion + 1} 的迁移逻辑`,
)
}
state = migration(state)
}
return state
}
/**
* 严格校验导入内容(T14)。任何一条任务不合法即整体拒绝,
* 以保证「非法文件不破坏现有数据」。
*/
export function validateImportPayload(value: unknown): Task[] {
if (!isRecord(value)) {
throw new DataValidationError('导入内容必须是一个 JSON 对象')
}
const version = value.schemaVersion
if (version !== undefined) {
if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
throw new DataValidationError('schemaVersion 字段不合法')
}
if (version > SCHEMA_VERSION) {
throw new DataValidationError(
`导入文件的版本 ${version} 高于当前支持的 ${SCHEMA_VERSION},无法导入`,
)
}
}
if (!Array.isArray(value.tasks)) {
throw new DataValidationError('导入内容缺少 tasks 数组')
}
const tasks: Task[] = []
value.tasks.forEach((item, index) => {
const task = coerceTask(item)
if (!task) {
throw new DataValidationError(`第 ${index + 1} 条任务格式不合法,已取消导入`)
}
tasks.push(task)
})
return tasks
}
export function parseImportJson(raw: string): Task[] {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new DataValidationError('导入文件不是合法的 JSON')
}
return validateImportPayload(parsed)
}
export function serializeState(tasks: readonly Task[], pretty = false): string {
const state: PersistedState = { schemaVersion: SCHEMA_VERSION, tasks: [...tasks] }
return pretty ? JSON.stringify(state, null, 2) : JSON.stringify(state)
}
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { DataValidationError, SNAPSHOT_KEY_PREFIX } from './schema'
import {
MAX_SNAPSHOTS,
SNAPSHOT_INDEX_KEY,
listSnapshots,
readSnapshot,
writeSnapshot,
} from './snapshot'
import type { StorageLike } from './storage'
interface FakeStorage extends StorageLike {
data: Record<string, string>
}
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
const data: Record<string, string> = { ...initial }
return {
data,
getItem: (key) => (key in data ? (data[key] ?? null) : null),
setItem: (key, value) => {
data[key] = value
},
removeItem: (key) => {
delete data[key]
},
}
}
function makeTask(id: string): Task {
return {
id,
title: `任务 ${id}`,
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
}
}
/** 依次生成互不相同的时间点,避免同毫秒导致键名冲突 */
function at(minute: number): Date {
return new Date(Date.UTC(2026, 9, 18, 10, minute, 0))
}
function snapshotKeys(storage: FakeStorage): string[] {
return Object.keys(storage.data).filter(
(key) => key.startsWith(SNAPSHOT_KEY_PREFIX) && key !== SNAPSHOT_INDEX_KEY,
)
}
describe('listSnapshots', () => {
it('空存储返回空数组', () => {
expect(listSnapshots(createFakeStorage())).toEqual([])
})
it('索引损坏时返回空数组而不是抛错', () => {
const storage = createFakeStorage({ [SNAPSHOT_INDEX_KEY]: '{broken' })
expect(listSnapshots(storage)).toEqual([])
})
it('索引不是数组时返回空数组', () => {
const storage = createFakeStorage({ [SNAPSHOT_INDEX_KEY]: '{"a":1}' })
expect(listSnapshots(storage)).toEqual([])
})
it('跳过内容已丢失的条目', () => {
const storage = createFakeStorage({
[SNAPSHOT_INDEX_KEY]: JSON.stringify([
{ key: `${SNAPSHOT_KEY_PREFIX}missing`, savedAt: '2026-10-18T10:00:00.000Z' },
]),
})
expect(listSnapshots(storage)).toEqual([])
})
})
describe('writeSnapshot', () => {
it('写入后可以列出,且返回快照信息', () => {
const storage = createFakeStorage()
const info = writeSnapshot(storage, [makeTask('a')], at(0))
expect(info).not.toBeNull()
expect(info?.savedAt).toBe(at(0).toISOString())
expect(listSnapshots(storage)).toEqual([info])
})
it('最新的排在最前', () => {
const storage = createFakeStorage()
writeSnapshot(storage, [makeTask('a')], at(0))
writeSnapshot(storage, [makeTask('b')], at(1))
writeSnapshot(storage, [makeTask('c')], at(2))
const savedAtList = listSnapshots(storage).map((info) => info.savedAt)
expect(savedAtList).toEqual([at(2).toISOString(), at(1).toISOString(), at(0).toISOString()])
})
it('最多保留 MAX_SNAPSHOTS 份,超出的数据也被删除', () => {
const storage = createFakeStorage()
for (let index = 0; index < MAX_SNAPSHOTS + 3; index += 1) {
writeSnapshot(storage, [makeTask(`t${index}`)], at(index))
}
expect(listSnapshots(storage)).toHaveLength(MAX_SNAPSHOTS)
expect(snapshotKeys(storage)).toHaveLength(MAX_SNAPSHOTS)
})
it('保留的是最新的若干份', () => {
const storage = createFakeStorage()
for (let index = 0; index < 5; index += 1) {
writeSnapshot(storage, [makeTask(`t${index}`)], at(index))
}
const savedAtList = listSnapshots(storage).map((info) => info.savedAt)
expect(savedAtList).toEqual([at(4).toISOString(), at(3).toISOString(), at(2).toISOString()])
})
it('keep 可自定义', () => {
const storage = createFakeStorage()
writeSnapshot(storage, [makeTask('a')], at(0), 1)
writeSnapshot(storage, [makeTask('b')], at(1), 1)
expect(listSnapshots(storage)).toHaveLength(1)
})
it('存储写入失败时返回 null 而不抛错', () => {
const storage: StorageLike = {
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError')
},
removeItem: () => undefined,
}
expect(writeSnapshot(storage, [makeTask('a')])).toBeNull()
})
it('可以保存空看板(调用方决定是否调用)', () => {
const storage = createFakeStorage()
const info = writeSnapshot(storage, [], at(0))
expect(info).not.toBeNull()
expect(readSnapshot(storage, info?.key ?? '')).toEqual([])
})
})
describe('readSnapshot', () => {
it('读回写入的任务', () => {
const storage = createFakeStorage()
const tasks = [makeTask('a'), makeTask('b')]
const info = writeSnapshot(storage, tasks, at(0))
expect(readSnapshot(storage, info?.key ?? '')).toEqual(tasks)
})
it('拒绝非快照键', () => {
const storage = createFakeStorage()
expect(() => readSnapshot(storage, 'kanban.tasks')).toThrow(DataValidationError)
})
it('快照不存在时返回空数组(parsePersistedState 对空值返回空看板)', () => {
const storage = createFakeStorage()
expect(readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}missing`)).toEqual([])
})
it('快照内容损坏时抛错', () => {
const storage = createFakeStorage({ [`${SNAPSHOT_KEY_PREFIX}bad`]: '{broken' })
expect(() => readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}bad`)).toThrow(DataValidationError)
})
})
+101
View File
@@ -0,0 +1,101 @@
/**
* 启动快照(T14)
*
* local-first 场景下,localStorage 是唯一数据副本。除了手动导出,
* 每次启动再自动留一份快照,用于「主数据损坏/被误清」时的兜底恢复。
*
* 索引单独存放在 SNAPSHOT_INDEX_KEY 中,而不是靠枚举存储键,
* 这样只需 StorageLike 的最小能力即可实现。
*/
import type { Task } from '@/types/task'
import { DataValidationError, SNAPSHOT_KEY_PREFIX, parsePersistedState, serializeState } from './schema'
import type { StorageLike } from './storage'
/**
* 索引键刻意不使用 SNAPSHOT_KEY_PREFIX 前缀,
* 避免任何「按前缀枚举」的代码把它误当成一份快照。
*/
export const SNAPSHOT_INDEX_KEY = 'kanban.snapshots'
/** 最多保留的快照份数 */
export const MAX_SNAPSHOTS = 3
export interface SnapshotInfo {
key: string
savedAt: string
}
function isSnapshotInfo(value: unknown): value is SnapshotInfo {
if (typeof value !== 'object' || value === null) return false
const record = value as Record<string, unknown>
return typeof record.key === 'string' && typeof record.savedAt === 'string'
}
function readIndex(storage: StorageLike): SnapshotInfo[] {
const raw = storage.getItem(SNAPSHOT_INDEX_KEY)
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isSnapshotInfo)
} catch {
return []
}
}
/** 列出可用快照,按时间倒序;内容已丢失的条目会被跳过 */
export function listSnapshots(storage: StorageLike): SnapshotInfo[] {
return readIndex(storage).filter((info) => storage.getItem(info.key) !== null)
}
/**
* 写入一份快照,并只保留最近 keep 份(超出的连同数据一起删除)。
* 快照属于「锦上添花」,因此任何存储异常都返回 null 而不抛出,
* 避免因为备份失败而阻断正常使用。
*/
export function writeSnapshot(
storage: StorageLike,
tasks: readonly Task[],
now: Date = new Date(),
keep: number = MAX_SNAPSHOTS,
): SnapshotInfo | null {
const savedAt = now.toISOString()
const key = `${SNAPSHOT_KEY_PREFIX}${savedAt}`
const previous = readIndex(storage)
try {
storage.setItem(key, serializeState(tasks))
} catch {
return null
}
const next = [{ key, savedAt }, ...previous].slice(0, Math.max(1, keep))
const keptKeys = new Set(next.map((info) => info.key))
for (const info of previous) {
if (keptKeys.has(info.key)) continue
try {
storage.removeItem(info.key)
} catch {
// 清理旧快照失败不影响本次快照
}
}
try {
storage.setItem(SNAPSHOT_INDEX_KEY, JSON.stringify(next))
} catch {
return null
}
return { key, savedAt }
}
/** 从指定快照读取任务 */
export function readSnapshot(storage: StorageLike, key: string): Task[] {
if (!key.startsWith(SNAPSHOT_KEY_PREFIX)) {
throw new DataValidationError(`不是合法的快照键:${key}`)
}
return parsePersistedState(storage.getItem(key)).tasks
}
+60
View File
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserStorage } from './storage'
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
afterEach(() => {
if (originalLocalStorage) Object.defineProperty(globalThis, 'localStorage', originalLocalStorage)
})
describe('resolveBrowserStorage', () => {
it('localStorage 可写时返回它', () => {
const storage = resolveBrowserStorage()
expect(storage).not.toBeNull()
expect(typeof storage?.getItem).toBe('function')
})
it('写入抛错时返回 null(Safari 隐私模式)', () => {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: () => {
throw new Error('SecurityError')
},
removeItem: () => undefined,
},
})
expect(resolveBrowserStorage()).toBeNull()
})
it('localStorage 不存在时返回 null', () => {
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: undefined })
expect(resolveBrowserStorage()).toBeNull()
})
it('探测用的临时键会被清理', () => {
const probeKeys: string[] = []
const data = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
probeKeys.push(key)
data.set(key, value)
},
removeItem: (key: string) => {
data.delete(key)
},
},
})
resolveBrowserStorage()
expect(probeKeys).toContain('__kanban_probe__')
expect(data.size).toBe(0)
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* 最小存储抽象(T04)
*
* 独立成模块是为了让 taskRepository 与 snapshot 都依赖它,
* 而不必互相 import(避免形成运行时循环依赖)。
*/
export interface StorageLike {
getItem(key: string): string | null
setItem(key: string, value: string): void
removeItem(key: string): void
}
/**
* 探测浏览器 localStorage 是否真正可写。
* Safari 隐私模式下 localStorage 存在但写入会抛错,因此必须实际试写一次。
*/
export function resolveBrowserStorage(): StorageLike | null {
try {
const storage = globalThis.localStorage
if (!storage) return null
const probeKey = '__kanban_probe__'
storage.setItem(probeKey, '1')
storage.removeItem(probeKey)
return storage
} catch {
return null
}
}
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import type { Task } from '@/types/task'
import { DataValidationError, SCHEMA_VERSION, serializeState } from './schema'
import {
StorageUnavailableError,
StorageWriteError,
createLocalStorageTaskRepository,
createMemoryTaskRepository,
type StorageLike,
} from './taskRepository'
interface FakeStorage extends StorageLike {
data: Record<string, string>
}
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
const data: Record<string, string> = { ...initial }
return {
data,
getItem: (key) => (key in data ? (data[key] ?? null) : null),
setItem: (key, value) => {
data[key] = value
},
removeItem: (key) => {
delete data[key]
},
}
}
function makeTask(overrides: Partial<Task> = {}): Task {
return {
id: 'task-1',
title: '写方案',
description: '',
status: 'todo',
priority: 2,
tags: [],
dueDate: null,
sortOrder: 1024,
createdAt: '2026-10-18T00:00:00.000Z',
updatedAt: '2026-10-18T00:00:00.000Z',
completedAt: null,
archivedAt: null,
...overrides,
}
}
describe('createLocalStorageTaskRepository', () => {
it('存储不可用时抛出 StorageUnavailableError', () => {
expect(() => createLocalStorageTaskRepository(null)).toThrow(StorageUnavailableError)
})
it('空存储读取为空数组', () => {
const repository = createLocalStorageTaskRepository(createFakeStorage(), 'kanban.tasks')
expect(repository.load()).toEqual([])
})
it('保存后可以读回,且落盘内容带 schemaVersion', () => {
const storage = createFakeStorage()
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
const tasks = [makeTask(), makeTask({ id: 'task-2', status: 'done' })]
repository.save(tasks)
expect(repository.load()).toEqual(tasks)
const raw = storage.getItem('kanban.tasks')
expect(raw).not.toBeNull()
expect(JSON.parse(raw ?? '{}').schemaVersion).toBe(SCHEMA_VERSION)
})
it('数据损坏时抛错,并把原始内容备份到 corrupt 键(不覆盖用户数据)', () => {
const storage = createFakeStorage({ 'kanban.tasks': '{broken json' })
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
expect(() => repository.load()).toThrow(DataValidationError)
const backupKeys = Object.keys(storage.data).filter((key) =>
key.startsWith('kanban.tasks.corrupt.'),
)
expect(backupKeys).toHaveLength(1)
expect(storage.data[backupKeys[0] ?? '']).toBe('{broken json')
// 原键未被清空,用户仍有机会手工恢复
expect(storage.getItem('kanban.tasks')).toBe('{broken json')
})
it('写入失败时抛出 StorageWriteError', () => {
const storage: StorageLike = {
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError')
},
removeItem: () => undefined,
}
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
expect(() => repository.save([makeTask()])).toThrow(StorageWriteError)
})
it('使用默认键名 kanban.tasks', () => {
const storage = createFakeStorage()
const repository = createLocalStorageTaskRepository(storage)
repository.save([makeTask()])
expect(storage.getItem('kanban.tasks')).toBe(serializeState([makeTask()]))
})
})
describe('createMemoryTaskRepository', () => {
it('初始为空', () => {
expect(createMemoryTaskRepository().load()).toEqual([])
})
it('接受初始数据并返回副本(外部修改不影响内部)', () => {
const initial = [makeTask()]
const repository = createMemoryTaskRepository(initial)
const loaded = repository.load()
loaded.push(makeTask({ id: 'task-2' }))
expect(repository.load()).toHaveLength(1)
})
it('保存后可以读回', () => {
const repository = createMemoryTaskRepository()
const tasks = [makeTask(), makeTask({ id: 'task-2' })]
repository.save(tasks)
expect(repository.load()).toEqual(tasks)
})
})
+146
View File
@@ -0,0 +1,146 @@
/**
* 任务持久化端口(T04 / T14)
*
* 设计(见 docs/adr.md ADR-002):
* - 接口与实现分离,Store 只依赖 TaskRepository,不感知 localStorage
* - 采用「整份快照读写」而非细粒度 CRUD:数据量小(约 60KB),
* 整份写入不会出现部分写入的不一致状态,且排序/导入/跨标签页同步
* 本质上都是快照操作
* - 快照(snapshot)是同一端口上的附加能力:自动备份与恢复走这里,
* 调用方无需关心底层是 localStorage 还是内存
*/
import type { Task } from '@/types/task'
import { DataValidationError, STORAGE_KEY, parsePersistedState, serializeState } from './schema'
import {
MAX_SNAPSHOTS,
listSnapshots,
readSnapshot,
writeSnapshot,
type SnapshotInfo,
} from './snapshot'
import { resolveBrowserStorage, type StorageLike } from './storage'
export type { StorageLike } from './storage'
export { resolveBrowserStorage } from './storage'
export type { SnapshotInfo } from './snapshot'
export interface TaskRepository {
/** 读取全部任务;数据为空时返回空数组 */
load(): Task[]
/** 覆盖写入全部任务 */
save(tasks: readonly Task[]): void
/** 写入一份快照并保留最近若干份;失败返回 null(不影响主流程) */
snapshot(tasks: readonly Task[]): SnapshotInfo | null
/** 列出可用快照,按时间倒序 */
snapshots(): SnapshotInfo[]
/** 从指定快照恢复任务 */
restoreSnapshot(key: string): Task[]
}
export class StorageUnavailableError extends Error {
constructor(message: string) {
super(message)
this.name = 'StorageUnavailableError'
}
}
export class StorageWriteError extends Error {
constructor(message: string) {
super(message)
this.name = 'StorageWriteError'
}
}
/**
* localStorage 实现。
* 读取时若数据损坏,会先把原始内容备份到 `<key>.corrupt.<时间戳>` 再抛错,
* 避免后续写入把用户原本可能恢复的数据覆盖掉。
*/
export function createLocalStorageTaskRepository(
storage: StorageLike | null = resolveBrowserStorage(),
key: string = STORAGE_KEY,
): TaskRepository {
const target = storage
if (!target) {
throw new StorageUnavailableError('浏览器本地存储不可用(可能处于隐私模式或已被浏览器禁用)')
}
const backupCorrupt = (raw: string): string | null => {
try {
const backupKey = `${key}.corrupt.${Date.now()}`
target.setItem(backupKey, raw)
return backupKey
} catch {
return null
}
}
return {
load() {
const raw = target.getItem(key)
try {
return parsePersistedState(raw).tasks
} catch (error) {
if (raw !== null && error instanceof DataValidationError) {
const backupKey = backupCorrupt(raw)
const suffix = backupKey ? `,原数据已备份到 ${backupKey}` : ''
throw new DataValidationError(`${error.message}${suffix}`)
}
throw error
}
},
save(tasks) {
try {
target.setItem(key, serializeState(tasks))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new StorageWriteError(`写入本地存储失败:${message}`)
}
},
snapshot: (tasks) => writeSnapshot(target, tasks),
snapshots: () => listSnapshots(target),
restoreSnapshot: (snapshotKey) => readSnapshot(target, snapshotKey),
}
}
interface MemorySnapshot {
key: string
savedAt: string
tasks: Task[]
}
/** 内存实现:用于测试,以及 localStorage 不可用时的降级(此时数据不会被保存) */
export function createMemoryTaskRepository(initial: readonly Task[] = []): TaskRepository {
let cache: Task[] = [...initial]
let snapshots: MemorySnapshot[] = []
return {
load: () => [...cache],
save: (tasks) => {
cache = [...tasks]
},
snapshot(tasks) {
const savedAt = new Date().toISOString()
const key = `memory-snapshot:${savedAt}`
snapshots = [
{ key, savedAt, tasks: [...tasks] },
...snapshots.filter((item) => item.key !== key),
].slice(0, MAX_SNAPSHOTS)
return { key, savedAt }
},
snapshots: () => snapshots.map(({ key, savedAt }) => ({ key, savedAt })),
restoreSnapshot(key) {
const found = snapshots.find((item) => item.key === key)
if (!found) throw new DataValidationError(`快照不存在:${key}`)
return [...found.tasks]
},
}
}