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