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