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
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import BackupPanel from '@/components/BackupPanel.vue'
import BoardColumn from '@/components/BoardColumn.vue'
import BoardToolbar from '@/components/BoardToolbar.vue'
import SideDrawer from '@/components/SideDrawer.vue'
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useFilterStore } from '@/stores/filters'
import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast'
import { COLUMNS, type ColumnId } from '@/types/task'
import { findArchivableTasks } from '@/utils/archive'
import { filterTasks } from '@/utils/filter'
import { groupByColumn } from '@/utils/sortOrder'
const store = useTaskStore()
const filters = useFilterStore()
const toast = useToastStore()
const { notice } = storeToRefs(store)
const { filter } = storeToRefs(filters)
const SHORTCUTS = [
{ keys: 'N', description: '聚焦「待办」列的快速新增输入框' },
{ keys: '/', description: '聚焦搜索框' },
{ keys: '?', description: '打开本帮助' },
{ keys: 'Esc', description: '关闭当前面板' },
] as const
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
const editingId = ref<string | null>(null)
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
const backupOpen = ref(false)
const helpOpen = ref(false)
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
function setColumnRef(element: unknown, index: number): void {
if (element) columnRefs.set(index, element as InstanceType<typeof BoardColumn>)
else columnRefs.delete(index)
}
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
const visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value)))
const visibleCount = computed(() =>
COLUMNS.reduce((total, column) => total + visibleColumns.value[column.id].length, 0),
)
/**
* 每列被隐藏的数量(被筛选掉 + 已归档)。
* 大于 0 说明该列渲染的是子集,此时必须禁用列内排序,
* 否则 Sortable 给出的子集下标会把全量顺序写坏。
*/
const hiddenCounts = computed(() => {
const result = {} as Record<ColumnId, number>
for (const column of COLUMNS) {
result[column.id] = store.byColumn[column.id].length - visibleColumns.value[column.id].length
}
return result
})
const hiddenTotal = computed(() =>
COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0),
)
const archivableTasks = computed(() => findArchivableTasks(store.tasks))
function archiveCompleted(): void {
const targets = archivableTasks.value
if (targets.length === 0) return
const ids = targets.map((task) => task.id)
const count = store.archiveTasks(ids)
if (count === 0) return
toast.show({
message: `已归档 ${count} 个已完成任务`,
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
})
}
function anyPanelOpen(): boolean {
return editingId.value !== null || backupOpen.value || helpOpen.value
}
/** 有面板打开时不响应全局快捷键,避免误触 */
function whenNoPanel(action: () => void): () => void {
return () => {
if (anyPanelOpen()) return
action()
}
}
useKeyboardShortcuts({
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
'?': whenNoPanel(() => {
helpOpen.value = true
}),
})
function toggleTask(id: string): void {
const task = store.tasks.find((item) => item.id === id)
if (!task) return
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
}
function openTask(id: string): void {
editingId.value = id
}
function closeDetail(): void {
editingId.value = null
}
function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }): void {
store.moveTask(payload.id, payload.toColumn, payload.toIndex)
}
</script>
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4 md:p-6">
<BoardToolbar
ref="toolbarRef"
@open-backup="backupOpen = true"
@open-help="helpOpen = true"
@archive="archiveCompleted"
/>
<p
v-if="notice"
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
>
{{ notice }}
</p>
<p v-if="hiddenTotal > 0" class="text-xs text-slate-500">
显示 {{ visibleCount }} / {{ store.tasks.length }} 个任务
</p>
<div class="grid min-h-0 flex-1 gap-4 overflow-y-auto md:grid-cols-3 md:overflow-hidden">
<BoardColumn
v-for="(column, index) in COLUMNS"
:key="column.id"
:ref="(element) => setColumnRef(element, index)"
:column="column"
:tasks="visibleColumns[column.id]"
:sort-disabled="hiddenCounts[column.id] > 0"
@toggle="toggleTask"
@open="openTask"
@move="handleMove"
/>
</div>
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
<BackupPanel :open="backupOpen" @close="backupOpen = false" />
<SideDrawer :open="helpOpen" title="快捷键" @close="helpOpen = false">
<dl class="flex flex-col gap-3 text-sm">
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
<dt
class="w-14 shrink-0 rounded border border-slate-300 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600"
>
{{ item.keys }}
</dt>
<dd class="text-slate-600">{{ item.description }}</dd>
</div>
</dl>
<p class="mt-4 text-xs text-slate-400">在输入框内输入时不会触发快捷键。</p>
</SideDrawer>
</div>
</template>