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:
@@ -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>
|
||||
Reference in New Issue
Block a user