Files
Kanban/src/components/BackupPanel.vue
T
chenshoulin 65e98199ba feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理
- 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标
- 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档
- 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照
- 标签治理:全局重命名、合并、删除;回收站任务不参与改写
- 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉
- UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组
- 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
2026-10-19 18:32:42 +08:00

265 lines
9.8 KiB
Vue
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.
<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>