feat: 回收站、批量编辑、合并导入与标签治理
- 数据:Task 增加 deletedAt,schema v2→v3;删除改为软删除,保留 30 天后自动清理 - 回收站:支持恢复、永久删除、二次确认清空;导航栏入口带数量角标 - 批量编辑:多选后统一移动列、改优先级、标签增删、设置截止日、归档 - 合并导入:按 id 匹配、updatedAt 新者胜;覆盖/合并二选一,执行前自动快照 - 标签治理:全局重命名、合并、删除;回收站任务不参与改写 - 看板语义读取统一改用 activeTasks,避免已删除任务泄漏到统计、今日与标签下拉 - UI:回收站图标与备份统一尺寸;批量编辑条统一控件高度并分组 - 测试:705 个单测通过,补充迁移、回收站、批量编辑、合并与标签用例
This commit is contained in:
@@ -6,6 +6,7 @@ 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'
|
||||
|
||||
@@ -19,8 +20,16 @@ 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 } | null>(null)
|
||||
const pending = ref<{
|
||||
text: string
|
||||
fileName: string
|
||||
count: number
|
||||
preview: MergeResult
|
||||
} | null>(null)
|
||||
|
||||
function exportNow(): void {
|
||||
const fileName = exportFileName()
|
||||
@@ -43,12 +52,19 @@ async function onFileChange(event: Event): Promise<void> {
|
||||
|
||||
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 }
|
||||
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 : '导入文件无法解析'
|
||||
@@ -63,9 +79,17 @@ function confirmImport(): void {
|
||||
const beforeCount = store.tasks.length
|
||||
|
||||
try {
|
||||
const count = store.importJson(target.text)
|
||||
const backupNote = beforeCount > 0 ? ',导入前的数据已自动备份' : ''
|
||||
message.value = `已从 ${target.fileName} 导入 ${count} 个任务${backupNote}`
|
||||
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 : '导入失败'
|
||||
@@ -142,18 +166,61 @@ function formatTime(iso: string): string {
|
||||
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"
|
||||
>
|
||||
<p class="text-sm text-amber-900 dark:text-amber-200">
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user