Files
Kanban/src/stores/tasks.ts
T
chenshoulin ede4bfe88f fix(data): 覆盖式导入与快照恢复前自动备份
- importJson / restoreSnapshot 覆盖前先写一份当前数据快照,误操作可回退
- 导入校验增加 id 唯一性检查,宽容读取路径保留首条
- 快照索引对同一时间点的重复写入去重
- 落盘失败时翻转 persistent,恢复后自动撤除告警
- 备份面板提示导入前数据已自动备份
2026-10-19 17:20:20 +08:00

461 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 任务 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
})
/**
* 现有任务里出现过的全部标签,按中文排序。
* 筛选下拉和编辑任务时的标签候选都用它,避免两处各算一份、结果不一致。
*/
const allTags = computed(() => {
const collected = new Set<string>()
for (const task of tasks.value) {
for (const tag of task.tags) collected.add(tag)
}
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
})
function persist(): void {
if (!repository) return
try {
repository.save(tasks.value)
// 之前写入失败过,现在恢复了:把告警撤掉,避免一直吓唬用户。
// 读取失败(数据损坏)时 persistent 仍是 true,不会被这里误清。
if (!persistent.value) {
persistent.value = true
notice.value = null
}
} catch (error) {
persistent.value = false
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()
}
/**
* 覆盖式操作(导入 / 恢复快照)执行前,把当前数据留一份快照。
*
* 这两类操作会整份替换内存与落盘数据,一旦选错文件或选错快照,
* 用户原本的数据就没有退路了。先快照一份,配合「备份与恢复」面板,
* 把不可逆操作变成可逆。
*
* @returns 是否真的写入了快照(空看板或存储不可用时跳过)
*/
function snapshotBeforeOverwrite(): boolean {
if (!repository || !persistent.value || tasks.value.length === 0) return false
try {
repository.snapshot(tasks.value)
refreshSnapshots()
return true
} catch {
// 快照只是兜底,失败不应阻断主流程
return false
}
}
/** 从快照恢复;返回实际恢复的任务数 */
function restoreSnapshot(key: string): number {
if (!repository) return 0
const restored = repository.restoreSnapshot(key)
if (restored.length === 0) return 0
// 先读快照再备份当前数据:即使备份把这份快照挤出保留窗口,
// 待恢复的内容也已经拿在手里。
snapshotBeforeOverwrite()
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)
// 先解析再备份:非法文件不应该产生任何副作用
snapshotBeforeOverwrite()
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,
allTags,
init,
createTask,
updateTask,
removeTask,
restoreTask,
moveTask,
exportJson,
importJson,
replaceAll,
archiveTasks,
unarchiveTasks,
refreshSnapshots,
restoreSnapshot,
dispose,
}
})