- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
265 lines
9.8 KiB
Vue
265 lines
9.8 KiB
Vue
<script setup lang="ts">
|
||
import dayjs from 'dayjs'
|
||
import { storeToRefs } from 'pinia'
|
||
import { ref } from 'vue'
|
||
|
||
import { parseImportJson } from '@/data/schema'
|
||
import { useTaskStore } from '@/stores/tasks'
|
||
import { downloadText, exportFileName } from '@/utils/exportFile'
|
||
import { mergeTasks, type MergeResult } from '@/utils/merge'
|
||
|
||
import SideDrawer from './SideDrawer.vue'
|
||
|
||
const props = defineProps<{ open: boolean }>()
|
||
|
||
const emit = defineEmits<{ close: [] }>()
|
||
|
||
const store = useTaskStore()
|
||
const { snapshots } = storeToRefs(store)
|
||
|
||
const message = ref<string | null>(null)
|
||
const error = ref<string | null>(null)
|
||
|
||
/** 导入模式:覆盖会替换全部数据,合并只按 id 更新 / 新增 */
|
||
const importMode = ref<'replace' | 'merge'>('replace')
|
||
|
||
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
|
||
const pending = ref<{
|
||
text: string
|
||
fileName: string
|
||
count: number
|
||
preview: MergeResult
|
||
} | null>(null)
|
||
|
||
function exportNow(): void {
|
||
const fileName = exportFileName()
|
||
try {
|
||
downloadText(store.exportJson(), fileName)
|
||
message.value = `已导出 ${store.tasks.length} 个任务到 ${fileName}`
|
||
error.value = null
|
||
} catch (cause) {
|
||
// 浏览器可能因权限或存储限制拒绝下载,这里必须让用户看到失败原因
|
||
error.value = cause instanceof Error ? cause.message : '导出失败'
|
||
}
|
||
}
|
||
|
||
async function onFileChange(event: Event): Promise<void> {
|
||
const input = event.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
// 清空以便连续选择同一个文件时也能触发 change
|
||
input.value = ''
|
||
if (!file) return
|
||
|
||
message.value = null
|
||
error.value = null
|
||
// 每次重新选文件都回到默认的覆盖模式,避免沿用上一次的选择误覆盖
|
||
importMode.value = 'replace'
|
||
|
||
try {
|
||
const text = await file.text()
|
||
// 先做一次纯校验,确认合法后才让用户确认导入
|
||
const tasks = parseImportJson(text)
|
||
pending.value = {
|
||
text,
|
||
fileName: file.name,
|
||
count: tasks.length,
|
||
preview: mergeTasks(store.tasks, tasks),
|
||
}
|
||
} catch (cause) {
|
||
pending.value = null
|
||
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
|
||
}
|
||
}
|
||
|
||
function confirmImport(): void {
|
||
const target = pending.value
|
||
if (!target) return
|
||
|
||
// 记录导入前的数量,用于提示「旧数据已自动备份」是否真的发生了
|
||
const beforeCount = store.tasks.length
|
||
|
||
try {
|
||
if (importMode.value === 'replace') {
|
||
const count = store.importJson(target.text)
|
||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
|
||
} else {
|
||
const { added, updated, skipped } = store.mergeJson(target.text)
|
||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||
message.value =
|
||
`已合并 ${target.fileName}:新增 ${added} 个,更新 ${updated} 个,跳过 ${skipped} 个` +
|
||
backupNote
|
||
}
|
||
error.value = null
|
||
} catch (cause) {
|
||
error.value = cause instanceof Error ? cause.message : '导入失败'
|
||
} finally {
|
||
pending.value = null
|
||
}
|
||
}
|
||
|
||
function restore(key: string): void {
|
||
const beforeCount = store.tasks.length
|
||
try {
|
||
const count = store.restoreSnapshot(key)
|
||
const backupNote = beforeCount > 0 ? ',恢复前的数据已自动备份' : ''
|
||
message.value =
|
||
count > 0 ? `已从备份恢复 ${count} 个任务${backupNote}` : '该备份是空的,未做改动'
|
||
error.value = null
|
||
} catch (cause) {
|
||
error.value = cause instanceof Error ? cause.message : '恢复失败'
|
||
}
|
||
}
|
||
|
||
function formatTime(iso: string): string {
|
||
return dayjs(iso).format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<SideDrawer :open="props.open" title="备份与恢复" @close="emit('close')">
|
||
<div class="flex flex-col gap-6">
|
||
<p
|
||
class="rounded-md bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
|
||
>
|
||
数据只保存在这台设备的浏览器里。清理浏览器数据会导致任务丢失,建议定期导出备份。
|
||
</p>
|
||
|
||
<p
|
||
v-if="message"
|
||
role="status"
|
||
class="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||
>
|
||
{{ message }}
|
||
</p>
|
||
<p
|
||
v-if="error"
|
||
role="alert"
|
||
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300"
|
||
>
|
||
{{ error }}
|
||
</p>
|
||
|
||
<section class="flex flex-col gap-2">
|
||
<h3 class="text-sm font-medium text-slate-700 dark:text-zinc-300">导出</h3>
|
||
<button
|
||
type="button"
|
||
class="self-start rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 dark:hover:bg-sky-500"
|
||
@click="exportNow"
|
||
>
|
||
导出为 JSON
|
||
</button>
|
||
</section>
|
||
|
||
<section class="flex flex-col gap-2">
|
||
<h3 class="text-sm font-medium text-slate-700 dark:text-zinc-300">导入</h3>
|
||
|
||
<input
|
||
type="file"
|
||
accept="application/json,.json"
|
||
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border file:border-slate-300 file:bg-white file:px-3 file:py-1.5 file:text-sm file:text-slate-600 dark:text-zinc-400 dark:file:border-zinc-600 dark:file:bg-zinc-800 dark:file:text-zinc-300"
|
||
aria-label="选择导入文件"
|
||
@change="onFileChange"
|
||
/>
|
||
|
||
<div
|
||
v-if="pending"
|
||
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/60"
|
||
>
|
||
<fieldset class="flex flex-col gap-1.5">
|
||
<legend class="sr-only">导入方式</legend>
|
||
<label class="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
|
||
<input
|
||
v-model="importMode"
|
||
type="radio"
|
||
name="import-mode"
|
||
value="replace"
|
||
class="mt-0.5"
|
||
/>
|
||
<span>
|
||
覆盖导入 —— 替换当前全部 {{ store.tasks.length }} 个任务
|
||
</span>
|
||
</label>
|
||
<label class="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
|
||
<input
|
||
v-model="importMode"
|
||
type="radio"
|
||
name="import-mode"
|
||
value="merge"
|
||
class="mt-0.5"
|
||
/>
|
||
<span>合并导入 —— 更新同 id 任务,新增其余任务</span>
|
||
</label>
|
||
</fieldset>
|
||
|
||
<p
|
||
v-if="importMode === 'replace'"
|
||
class="border-t border-amber-200 pt-2 text-sm text-amber-900 dark:border-amber-800/60 dark:text-amber-200"
|
||
>
|
||
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
||
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。 覆盖前会自动把当前数据
|
||
存一份到下方「自动备份」,选错文件也能从那里恢复。
|
||
</p>
|
||
<p
|
||
v-else
|
||
class="border-t border-amber-200 pt-2 text-sm text-amber-900 dark:border-amber-800/60 dark:text-amber-200"
|
||
>
|
||
「{{ pending.fileName }}」共 {{ pending.count }} 个任务:新增
|
||
<strong>{{ pending.preview.added }}</strong> 个 / 更新
|
||
<strong>{{ pending.preview.updated }}</strong> 个 / 保持
|
||
<strong>{{ Math.max(0, store.tasks.length - pending.preview.updated) }}</strong> 个。
|
||
<span v-if="pending.preview.skipped > 0">
|
||
其中 {{ pending.preview.skipped }} 个同 id 任务与本地相同或更旧,已跳过。
|
||
</span>
|
||
合并前会自动把当前数据存一份到下方「自动备份」。
|
||
</p>
|
||
|
||
<div class="flex gap-2">
|
||
<button
|
||
type="button"
|
||
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
|
||
@click="confirmImport"
|
||
>
|
||
{{ importMode === 'replace' ? '确认覆盖导入' : '确认合并导入' }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||
@click="pending = null"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="flex flex-col gap-2">
|
||
<h3 class="text-sm font-medium text-slate-700 dark:text-zinc-300">自动备份</h3>
|
||
<p class="text-xs text-slate-500 dark:text-zinc-400">
|
||
每次打开看板时自动留一份,最多保留 3 份。
|
||
</p>
|
||
|
||
<ul v-if="snapshots.length > 0" class="flex flex-col gap-2">
|
||
<li
|
||
v-for="item in snapshots"
|
||
:key="item.key"
|
||
class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2 dark:border-zinc-700"
|
||
>
|
||
<span class="text-sm text-slate-600 dark:text-zinc-300">
|
||
{{ formatTime(item.savedAt) }}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50 dark:text-sky-400 dark:hover:bg-sky-950"
|
||
@click="restore(item.key)"
|
||
>
|
||
恢复
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="text-sm text-slate-400 dark:text-zinc-500">暂无自动备份</p>
|
||
</section>
|
||
</div>
|
||
</SideDrawer>
|
||
</template>
|