- importJson / restoreSnapshot 覆盖前先写一份当前数据快照,误操作可回退 - 导入校验增加 id 唯一性检查,宽容读取路径保留首条 - 快照索引对同一时间点的重复写入去重 - 落盘失败时翻转 persistent,恢复后自动撤除告警 - 备份面板提示导入前数据已自动备份
461 lines
13 KiB
TypeScript
461 lines
13 KiB
TypeScript
/**
|
||
* 任务 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,
|
||
}
|
||
})
|