fix(data): 覆盖式导入与快照恢复前自动备份
- importJson / restoreSnapshot 覆盖前先写一份当前数据快照,误操作可回退 - 导入校验增加 id 唯一性检查,宽容读取路径保留首条 - 快照索引对同一时间点的重复写入去重 - 落盘失败时翻转 persistent,恢复后自动撤除告警 - 备份面板提示导入前数据已自动备份
This commit is contained in:
@@ -118,6 +118,7 @@ describe('BackupPanel 导入', () => {
|
|||||||
|
|
||||||
expect(store.tasks.map((task) => task.title).sort()).toEqual(['新任务一', '新任务二'])
|
expect(store.tasks.map((task) => task.title).sort()).toEqual(['新任务一', '新任务二'])
|
||||||
expect(wrapper.text()).toContain('已从 backup.json 导入 2 个任务')
|
expect(wrapper.text()).toContain('已从 backup.json 导入 2 个任务')
|
||||||
|
expect(wrapper.text()).toContain('导入前的数据已自动备份')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('取消确认后数据保持不变', async () => {
|
it('取消确认后数据保持不变', async () => {
|
||||||
|
|||||||
@@ -59,9 +59,13 @@ function confirmImport(): void {
|
|||||||
const target = pending.value
|
const target = pending.value
|
||||||
if (!target) return
|
if (!target) return
|
||||||
|
|
||||||
|
// 记录导入前的数量,用于提示「旧数据已自动备份」是否真的发生了
|
||||||
|
const beforeCount = store.tasks.length
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const count = store.importJson(target.text)
|
const count = store.importJson(target.text)
|
||||||
message.value = `已从 ${target.fileName} 导入 ${count} 个任务`
|
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||||||
|
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
|
||||||
error.value = null
|
error.value = null
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.value = cause instanceof Error ? cause.message : '导入失败'
|
error.value = cause instanceof Error ? cause.message : '导入失败'
|
||||||
@@ -71,9 +75,12 @@ function confirmImport(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restore(key: string): void {
|
function restore(key: string): void {
|
||||||
|
const beforeCount = store.tasks.length
|
||||||
try {
|
try {
|
||||||
const count = store.restoreSnapshot(key)
|
const count = store.restoreSnapshot(key)
|
||||||
message.value = count > 0 ? `已从备份恢复 ${count} 个任务` : '该备份是空的,未做改动'
|
const backupNote = beforeCount > 0 ? ',恢复前的数据已自动备份' : ''
|
||||||
|
message.value =
|
||||||
|
count > 0 ? `已从备份恢复 ${count} 个任务${backupNote}` : '该备份是空的,未做改动'
|
||||||
error.value = null
|
error.value = null
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
error.value = cause instanceof Error ? cause.message : '恢复失败'
|
error.value = cause instanceof Error ? cause.message : '恢复失败'
|
||||||
@@ -137,7 +144,8 @@ function formatTime(iso: string): string {
|
|||||||
>
|
>
|
||||||
<p class="text-sm text-amber-900 dark:text-amber-200">
|
<p class="text-sm text-amber-900 dark:text-amber-200">
|
||||||
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
||||||
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
|
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。 覆盖前会自动把当前数据
|
||||||
|
存一份到下方「自动备份」,选错文件也能从那里恢复。
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ describe('coerceTaskList', () => {
|
|||||||
expect(coerceTaskList(null)).toEqual([])
|
expect(coerceTaskList(null)).toEqual([])
|
||||||
expect(coerceTaskList({})).toEqual([])
|
expect(coerceTaskList({})).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('重复 id 只保留先出现的那条', () => {
|
||||||
|
const list = coerceTaskList([
|
||||||
|
validTask({ id: 'dup', title: '第一条' }),
|
||||||
|
validTask({ id: 'dup', title: '第二条' }),
|
||||||
|
validTask({ id: 'other' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(list.map((task) => task.id)).toEqual(['dup', 'other'])
|
||||||
|
expect(list[0]?.title).toBe('第一条')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('parsePersistedState', () => {
|
describe('parsePersistedState', () => {
|
||||||
@@ -240,6 +251,18 @@ describe('导入校验(严格)', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('id 重复时整体拒绝,并指出重复的两条', () => {
|
||||||
|
const duplicated = [
|
||||||
|
validTask({ id: 'same' }),
|
||||||
|
validTask({ id: 'other' }),
|
||||||
|
validTask({ id: 'same' }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(() => validateImportPayload({ tasks: duplicated })).toThrow(
|
||||||
|
/第 3 条与第 1 条任务的 id 相同(same)/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('缺少 tasks 数组时抛错', () => {
|
it('缺少 tasks 数组时抛错', () => {
|
||||||
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
|
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
|
||||||
})
|
})
|
||||||
|
|||||||
+19
-4
@@ -66,9 +66,7 @@ export function coerceTask(value: unknown): Task | null {
|
|||||||
if (!isPriority(priority)) return null
|
if (!isPriority(priority)) return null
|
||||||
|
|
||||||
const rawTags = Array.isArray(value.tags) ? value.tags : []
|
const rawTags = Array.isArray(value.tags) ? value.tags : []
|
||||||
const tags = rawTags.filter(
|
const tags = rawTags.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '')
|
||||||
(tag): tag is string => typeof tag === 'string' && tag.trim() !== '',
|
|
||||||
)
|
|
||||||
|
|
||||||
const dueDate = value.dueDate
|
const dueDate = value.dueDate
|
||||||
const sortOrder = value.sortOrder
|
const sortOrder = value.sortOrder
|
||||||
@@ -96,9 +94,15 @@ export function coerceTask(value: unknown): Task | null {
|
|||||||
export function coerceTaskList(value: unknown): Task[] {
|
export function coerceTaskList(value: unknown): Task[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const tasks: Task[] = []
|
const tasks: Task[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
const task = coerceTask(item)
|
const task = coerceTask(item)
|
||||||
if (task) tasks.push(task)
|
if (!task) continue
|
||||||
|
// 重复 id 会让 Vue 的 :key 冲突、findIndex 只命中第一条,
|
||||||
|
// 宽容读取时保留先出现的那条,丢弃后续重复项。
|
||||||
|
if (seen.has(task.id)) continue
|
||||||
|
seen.add(task.id)
|
||||||
|
tasks.push(task)
|
||||||
}
|
}
|
||||||
return tasks
|
return tasks
|
||||||
}
|
}
|
||||||
@@ -195,11 +199,22 @@ export function validateImportPayload(value: unknown): Task[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tasks: Task[] = []
|
const tasks: Task[] = []
|
||||||
|
/** 记录每个 id 首次出现的下标,用于给出「第 N 条与第 M 条重复」的定位信息 */
|
||||||
|
const firstIndexById = new Map<string, number>()
|
||||||
value.tasks.forEach((item, index) => {
|
value.tasks.forEach((item, index) => {
|
||||||
const task = coerceTask(item)
|
const task = coerceTask(item)
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new DataValidationError(`第 ${index + 1} 条任务格式不合法,已取消导入`)
|
throw new DataValidationError(`第 ${index + 1} 条任务格式不合法,已取消导入`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstIndex = firstIndexById.get(task.id)
|
||||||
|
if (firstIndex !== undefined) {
|
||||||
|
throw new DataValidationError(
|
||||||
|
`第 ${index + 1} 条与第 ${firstIndex + 1} 条任务的 id 相同(${task.id}),已取消导入`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstIndexById.set(task.id, index)
|
||||||
tasks.push(task)
|
tasks.push(task)
|
||||||
})
|
})
|
||||||
return tasks
|
return tasks
|
||||||
|
|||||||
@@ -129,6 +129,17 @@ describe('writeSnapshot', () => {
|
|||||||
expect(listSnapshots(storage)).toHaveLength(1)
|
expect(listSnapshots(storage)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('同一时间点重复写入不会产生重复索引项', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
writeSnapshot(storage, [makeTask('a')], at(0))
|
||||||
|
writeSnapshot(storage, [makeTask('b')], at(0))
|
||||||
|
|
||||||
|
expect(listSnapshots(storage)).toHaveLength(1)
|
||||||
|
expect(readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}${at(0).toISOString()}`)).toEqual([
|
||||||
|
makeTask('b'),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('存储写入失败时返回 null 而不抛错', () => {
|
it('存储写入失败时返回 null 而不抛错', () => {
|
||||||
const storage: StorageLike = {
|
const storage: StorageLike = {
|
||||||
getItem: () => null,
|
getItem: () => null,
|
||||||
|
|||||||
+11
-2
@@ -10,7 +10,12 @@
|
|||||||
|
|
||||||
import type { Task } from '@/types/task'
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
import { DataValidationError, SNAPSHOT_KEY_PREFIX, parsePersistedState, serializeState } from './schema'
|
import {
|
||||||
|
DataValidationError,
|
||||||
|
SNAPSHOT_KEY_PREFIX,
|
||||||
|
parsePersistedState,
|
||||||
|
serializeState,
|
||||||
|
} from './schema'
|
||||||
import type { StorageLike } from './storage'
|
import type { StorageLike } from './storage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +76,11 @@ export function writeSnapshot(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [{ key, savedAt }, ...previous].slice(0, Math.max(1, keep))
|
const next = [
|
||||||
|
{ key, savedAt },
|
||||||
|
// 同一毫秒内重复写入会产生相同 key,先剔除旧条目避免索引里出现重复项
|
||||||
|
...previous.filter((info) => info.key !== key),
|
||||||
|
].slice(0, Math.max(1, keep))
|
||||||
const keptKeys = new Set(next.map((info) => info.key))
|
const keptKeys = new Set(next.map((info) => info.key))
|
||||||
|
|
||||||
for (const info of previous) {
|
for (const info of previous) {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
|
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
import { useTaskStore } from './tasks'
|
import { useTaskStore } from './tasks'
|
||||||
|
|
||||||
@@ -64,7 +65,9 @@ describe('初始化', () => {
|
|||||||
const store = freshStore()
|
const store = freshStore()
|
||||||
store.createTask({ title: 'A', tags: ['前端', '设计'] })
|
store.createTask({ title: 'A', tags: ['前端', '设计'] })
|
||||||
store.createTask({ title: 'B', status: 'doing', tags: ['设计', 'Bug'] })
|
store.createTask({ title: 'B', status: 'doing', tags: ['设计', 'Bug'] })
|
||||||
expect(store.allTags).toEqual(['Bug', '前端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')))
|
expect(store.allTags).toEqual(
|
||||||
|
['Bug', '前端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('没有任务时 allTags 为空数组', () => {
|
it('没有任务时 allTags 为空数组', () => {
|
||||||
@@ -285,6 +288,30 @@ describe('持久化', () => {
|
|||||||
const second = freshStore(repository)
|
const second = freshStore(repository)
|
||||||
expect(second.tasks).toHaveLength(0)
|
expect(second.tasks).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('写入失败时标记为非持久化并提示,恢复后自动清除', () => {
|
||||||
|
const base = createMemoryTaskRepository()
|
||||||
|
let shouldFail = true
|
||||||
|
const repository: TaskRepository = {
|
||||||
|
...base,
|
||||||
|
save(tasks) {
|
||||||
|
if (shouldFail) throw new Error('写入本地存储失败:QuotaExceededError')
|
||||||
|
base.save(tasks)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = freshStore(repository)
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
|
||||||
|
expect(store.persistent).toBe(false)
|
||||||
|
expect(store.notice).toContain('QuotaExceededError')
|
||||||
|
|
||||||
|
shouldFail = false
|
||||||
|
store.createTask({ title: 'B' })
|
||||||
|
|
||||||
|
expect(store.persistent).toBe(true)
|
||||||
|
expect(store.notice).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('导出与导入', () => {
|
describe('导出与导入', () => {
|
||||||
@@ -330,6 +357,74 @@ describe('导出与导入', () => {
|
|||||||
store.importJson(payload)
|
store.importJson(payload)
|
||||||
expect(titles(store.tasks)).toEqual(['新数据'])
|
expect(titles(store.tasks)).toEqual(['新数据'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('导入前会把当前数据写入快照,误导入可恢复', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
vi.setSystemTime(new Date('2026-10-19T00:00:00.000Z'))
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '导入前的旧任务' })
|
||||||
|
expect(store.snapshots).toHaveLength(0)
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z'))
|
||||||
|
const count = store.importJson(
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
tasks: [{ id: 'new', title: '新任务', status: 'todo', priority: 2, sortOrder: 1024 }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(count).toBe(1)
|
||||||
|
expect(titles(store.tasks)).toEqual(['新任务'])
|
||||||
|
expect(store.snapshots).toHaveLength(1)
|
||||||
|
|
||||||
|
const key = store.snapshots[0]?.key ?? ''
|
||||||
|
expect(key).not.toBe('')
|
||||||
|
expect(store.restoreSnapshot(key)).toBe(1)
|
||||||
|
expect(titles(store.tasks)).toEqual(['导入前的旧任务'])
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恢复快照前会先备份当前数据', () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
vi.setSystemTime(new Date('2026-10-19T00:00:00.000Z'))
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const store = freshStore(repository)
|
||||||
|
store.createTask({ title: '当前任务' })
|
||||||
|
|
||||||
|
const oldTask: Task = {
|
||||||
|
id: 'old',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-10-19T00:00:01.000Z'))
|
||||||
|
const snapshot = repository.snapshot([oldTask])
|
||||||
|
store.refreshSnapshots()
|
||||||
|
|
||||||
|
vi.setSystemTime(new Date('2026-10-19T00:00:02.000Z'))
|
||||||
|
expect(store.restoreSnapshot(snapshot?.key ?? '')).toBe(1)
|
||||||
|
expect(titles(store.tasks)).toEqual(['快照里的旧任务'])
|
||||||
|
|
||||||
|
// 恢复前的「当前任务」也应留下备份,而不是被静默覆盖
|
||||||
|
const backedUp = store.snapshots.map((info) => titles(repository.restoreSnapshot(info.key)))
|
||||||
|
expect(backedUp.some((items) => items.includes('当前任务'))).toBe(true)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('归档', () => {
|
describe('归档', () => {
|
||||||
|
|||||||
+41
-3
@@ -19,7 +19,12 @@ import {
|
|||||||
} from '@/data/taskRepository'
|
} from '@/data/taskRepository'
|
||||||
import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
|
import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
|
||||||
import { createId } from '@/utils/id'
|
import { createId } from '@/utils/id'
|
||||||
import { groupByColumn, moveWithinColumn, sortByOrder, sortOrderForPosition } from '@/utils/sortOrder'
|
import {
|
||||||
|
groupByColumn,
|
||||||
|
moveWithinColumn,
|
||||||
|
sortByOrder,
|
||||||
|
sortOrderForPosition,
|
||||||
|
} from '@/utils/sortOrder'
|
||||||
|
|
||||||
/** 跨标签页同步使用的频道名 */
|
/** 跨标签页同步使用的频道名 */
|
||||||
export const SYNC_CHANNEL_NAME = 'kanban.sync'
|
export const SYNC_CHANNEL_NAME = 'kanban.sync'
|
||||||
@@ -105,7 +110,14 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
if (!repository) return
|
if (!repository) return
|
||||||
try {
|
try {
|
||||||
repository.save(tasks.value)
|
repository.save(tasks.value)
|
||||||
|
// 之前写入失败过,现在恢复了:把告警撤掉,避免一直吓唬用户。
|
||||||
|
// 读取失败(数据损坏)时 persistent 仍是 true,不会被这里误清。
|
||||||
|
if (!persistent.value) {
|
||||||
|
persistent.value = true
|
||||||
|
notice.value = null
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
persistent.value = false
|
||||||
notice.value = error instanceof Error ? error.message : '保存失败'
|
notice.value = error instanceof Error ? error.message : '保存失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,11 +170,35 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
refreshSnapshots()
|
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 {
|
function restoreSnapshot(key: string): number {
|
||||||
if (!repository) return 0
|
if (!repository) return 0
|
||||||
const restored = repository.restoreSnapshot(key)
|
const restored = repository.restoreSnapshot(key)
|
||||||
if (restored.length === 0) return 0
|
if (restored.length === 0) return 0
|
||||||
|
// 先读快照再备份当前数据:即使备份把这份快照挤出保留窗口,
|
||||||
|
// 待恢复的内容也已经拿在手里。
|
||||||
|
snapshotBeforeOverwrite()
|
||||||
tasks.value = sortByOrder(restored)
|
tasks.value = sortByOrder(restored)
|
||||||
commit()
|
commit()
|
||||||
return restored.length
|
return restored.length
|
||||||
@@ -189,8 +225,7 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
repository = createMemoryTaskRepository()
|
repository = createMemoryTaskRepository()
|
||||||
persistent.value = false
|
persistent.value = false
|
||||||
notice.value =
|
notice.value = error instanceof Error ? error.message : '本地存储不可用,本次改动不会被保存'
|
||||||
error instanceof Error ? error.message : '本地存储不可用,本次改动不会被保存'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,10 +373,13 @@ export const useTaskStore = defineStore('tasks', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 覆盖式导入(T14)。校验失败会抛错,此时现有数据保持不变。
|
* 覆盖式导入(T14)。校验失败会抛错,此时现有数据保持不变。
|
||||||
|
* 校验通过后、覆盖之前会自动留一份快照,误导入可以回到导入前的状态。
|
||||||
* @returns 成功导入的任务数量
|
* @returns 成功导入的任务数量
|
||||||
*/
|
*/
|
||||||
function importJson(raw: string): number {
|
function importJson(raw: string): number {
|
||||||
const imported = parseImportJson(raw)
|
const imported = parseImportJson(raw)
|
||||||
|
// 先解析再备份:非法文件不应该产生任何副作用
|
||||||
|
snapshotBeforeOverwrite()
|
||||||
tasks.value = sortByOrder(imported)
|
tasks.value = sortByOrder(imported)
|
||||||
commit()
|
commit()
|
||||||
return imported.length
|
return imported.length
|
||||||
|
|||||||
Reference in New Issue
Block a user