feat: 回收站、批量编辑、合并导入与标签治理

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