/** * 任务 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> 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() 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([]) const ready = ref(false) /** 数据是否真正落盘;false 表示当前处于内存降级模式 */ const persistent = ref(false) /** 需要向用户展示的存储异常提示 */ const notice = ref(null) /** 可用的启动快照,按时间倒序 */ const snapshots = ref([]) let repository: TaskRepository | null = null let channel: BroadcastChannel | null = null const byColumn = computed(() => groupByColumn(tasks.value)) const counts = computed(() => { const result = {} as Record 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() 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, } })