feat: 个人轻量看板工具 MVP

从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。

功能
- 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序
- 快速新增、详情编辑、删除撤销、已完成归档
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
- 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片
- 导出导入 JSON;启动时自动留快照,保留最近 3 份
- 键盘快捷键(N / 「/」/ ? / Esc)
- PWA:可安装、离线可用、更新提示

技术要点
- 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表
  显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt)
- 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽
- 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据
- 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据
- 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005)

验证
- 单元测试 437 个(33 个文件),行覆盖率 96.8%
- 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景
- npm run build 通过,产物可静态托管

注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
This commit is contained in:
chenshoulin
2026-10-18 11:59:49 +08:00
commit 4e064e82ea
98 changed files with 20084 additions and 0 deletions
+183
View File
@@ -0,0 +1,183 @@
<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 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)
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
const pending = ref<{ text: string; fileName: string; count: number } | 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
try {
const text = await file.text()
// 先做一次纯校验,确认合法后才让用户确认覆盖
const tasks = parseImportJson(text)
pending.value = { text, fileName: file.name, count: tasks.length }
} catch (cause) {
pending.value = null
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
}
}
function confirmImport(): void {
const target = pending.value
if (!target) return
try {
const count = store.importJson(target.text)
message.value = `已从 ${target.fileName} 导入 ${count} 个任务`
error.value = null
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '导入失败'
} finally {
pending.value = null
}
}
function restore(key: string): void {
try {
const count = store.restoreSnapshot(key)
message.value = count > 0 ? `已从备份恢复 ${count} 个任务` : '该备份是空的,未做改动'
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">
数据只保存在这台设备的浏览器里。清理浏览器数据会导致任务丢失,建议定期导出备份。
</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"
>
{{ 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"
>
{{ error }}
</p>
<section class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-slate-700">导出</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"
@click="exportNow"
>
导出为 JSON
</button>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-slate-700">导入</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"
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"
>
<p class="text-sm text-amber-900">
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
</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"
@click="confirmImport"
>
确认覆盖导入
</button>
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white"
@click="pending = null"
>
取消
</button>
</div>
</div>
</section>
<section class="flex flex-col gap-2">
<h3 class="text-sm font-medium text-slate-700">自动备份</h3>
<p class="text-xs text-slate-500">每次打开看板时自动留一份,最多保留 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"
>
<span class="text-sm text-slate-600">{{ 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"
@click="restore(item.key)"
>
恢复
</button>
</li>
</ul>
<p v-else class="text-sm text-slate-400">暂无自动备份</p>
</section>
</div>
</SideDrawer>
</template>