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
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { PRIORITY_LABELS, type Priority, type Task } from '@/types/task'
import DatePicker from './DatePicker.vue'
import SideDrawer from './SideDrawer.vue'
import TagInput from './TagInput.vue'
const props = defineProps<{ task: Task | null }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const toast = useToastStore()
const PRIORITIES: readonly Priority[] = [1, 2, 3]
const title = ref('')
const description = ref('')
const priority = ref<Priority>(2)
const dueDate = ref<string | null>(null)
const tags = ref<string[]>([])
const titleError = ref<string | null>(null)
/**
* 打开时把任务字段复制到本地草稿。
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
*/
watch(
() => props.task,
(task) => {
title.value = task?.title ?? ''
description.value = task?.description ?? ''
priority.value = task?.priority ?? 2
dueDate.value = task?.dueDate ?? null
tags.value = task ? [...task.tags] : []
titleError.value = null
},
{ immediate: true },
)
function save(): void {
const task = props.task
if (!task) return
const trimmed = title.value.trim()
if (!trimmed) {
titleError.value = '标题不能为空'
return
}
store.updateTask(task.id, {
title: trimmed,
description: description.value.trim(),
priority: priority.value,
dueDate: dueDate.value,
tags: [...tags.value],
})
emit('close')
}
/** 删除不弹二次确认,改由 Toast 的撤销兜底(误删成本更低) */
function remove(): void {
const task = props.task
if (!task) return
const removed = store.removeTask(task.id)
emit('close')
if (!removed) return
toast.show({
message: `已删除「${removed.title}」`,
action: { label: '撤销', run: () => store.restoreTask(removed) },
})
}
</script>
<template>
<SideDrawer :open="task !== null" title="任务详情" @close="emit('close')">
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">标题</span>
<input
v-model="title"
type="text"
class="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
aria-label="标题"
@input="titleError = null"
/>
<span v-if="titleError" class="text-xs text-rose-600">{{ titleError }}</span>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">描述</span>
<textarea
v-model="description"
rows="4"
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
aria-label="描述"
/>
</label>
<fieldset class="flex flex-col gap-1.5">
<legend class="mb-1.5 text-sm font-medium text-slate-600">优先级</legend>
<div class="flex gap-2">
<button
v-for="value in PRIORITIES"
:key="value"
type="button"
class="rounded-md border px-3 py-1 text-sm transition"
:class="
priority === value
? 'border-sky-400 bg-sky-50 text-sky-700'
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
"
:aria-pressed="priority === value"
@click="priority = value"
>
{{ PRIORITY_LABELS[value] }}
</button>
</div>
</fieldset>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">截止日期</span>
<DatePicker v-model="dueDate" />
</div>
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-slate-600">标签</span>
<TagInput v-model="tags" />
</div>
</form>
<template #footer>
<div class="flex items-center justify-between">
<button
type="button"
class="rounded-md px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50"
@click="remove"
>
删除
</button>
<div class="flex gap-2">
<button
type="button"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50"
@click="emit('close')"
>
取消
</button>
<button
type="button"
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700"
@click="save"
>
保存
</button>
</div>
</div>
</template>
</SideDrawer>
</template>