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
+81
View File
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { computed } from 'vue'
import { PRIORITY_LABELS, type Task } from '@/types/task'
import { formatDueDate, isOverdue } from '@/utils/date'
const MAX_VISIBLE_TAGS = 3
const props = defineProps<{ task: Task }>()
const emit = defineEmits<{ toggle: [id: string]; open: [id: string] }>()
const isDone = computed(() => props.task.status === 'done')
const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
/** 三档优先级使用不同色系,保证一眼可区分 */
const priorityClass = computed(() => {
switch (props.task.priority) {
case 3:
return 'bg-rose-100 text-rose-700'
case 2:
return 'bg-amber-100 text-amber-700'
default:
return 'bg-slate-100 text-slate-500'
}
})
</script>
<template>
<article
class="cursor-pointer rounded-md border border-slate-200 bg-white px-2.5 py-2 shadow-sm transition"
:class="isDone ? 'opacity-60' : 'hover:border-slate-300 hover:shadow'"
:data-task-id="task.id"
@click="emit('open', task.id)"
>
<div class="flex items-start gap-2">
<button
type="button"
class="mt-0.5 size-4 shrink-0 rounded-full border transition"
:class="
isDone ? 'border-emerald-500 bg-emerald-500' : 'border-slate-300 hover:border-emerald-500'
"
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
:aria-pressed="isDone"
@click.stop="emit('toggle', task.id)"
/>
<h3
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
>
{{ task.title }}
</h3>
</div>
<div class="mt-2 flex flex-wrap items-center gap-1.5 pl-6">
<span class="rounded px-1.5 py-0.5 text-xs" :class="priorityClass">
{{ PRIORITY_LABELS[task.priority] }}
</span>
<span
v-if="task.dueDate"
class="rounded px-1.5 py-0.5 text-xs"
:class="overdue ? 'bg-rose-100 font-medium text-rose-700' : 'bg-slate-100 text-slate-600'"
>
{{ overdue ? '⚠ ' : '' }}{{ formatDueDate(task.dueDate) }}
</span>
<span
v-for="tag in visibleTags"
:key="tag"
class="rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
>
#{{ tag }}
</span>
<span v-if="hiddenTagCount > 0" class="text-xs text-slate-400">+{{ hiddenTagCount }}</span>
</div>
</article>
</template>