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,180 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFilterStore } from '@/stores/filters'
|
||||
import { useTaskStore } from '@/stores/tasks'
|
||||
import { PRIORITY_LABELS, type Priority } from '@/types/task'
|
||||
import { isDueToday, isOverdue } from '@/utils/date'
|
||||
import { findArchivableTasks } from '@/utils/archive'
|
||||
|
||||
const emit = defineEmits<{ 'open-backup': []; 'open-help': []; archive: [] }>()
|
||||
|
||||
const store = useTaskStore()
|
||||
const filters = useFilterStore()
|
||||
const { keyword, priorities, tags, due, active, includeArchived } = storeToRefs(filters)
|
||||
|
||||
const searchRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/** 由高到低排列,与看板中「高优先级优先处理」的心智一致 */
|
||||
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
||||
|
||||
const dueTodayCount = computed(
|
||||
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
|
||||
)
|
||||
|
||||
const overdueCount = computed(
|
||||
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
|
||||
)
|
||||
|
||||
/** 从现有任务里收集标签,作为筛选候选项 */
|
||||
const allTags = computed(() => {
|
||||
const collected = new Set<string>()
|
||||
for (const task of store.tasks) {
|
||||
for (const tag of task.tags) collected.add(tag)
|
||||
}
|
||||
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
|
||||
})
|
||||
|
||||
/** 当前可归档(已完成且超过保留天数)的任务数量 */
|
||||
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
|
||||
|
||||
function focusSearch(): void {
|
||||
searchRef.value?.focus()
|
||||
}
|
||||
|
||||
defineExpose({ focusSearch })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span class="text-sm text-slate-500">共 {{ store.tasks.length }} 个任务</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-sm transition"
|
||||
:class="
|
||||
due === 'today' ? 'bg-amber-100 text-amber-800' : 'text-slate-500 hover:bg-slate-200'
|
||||
"
|
||||
:aria-pressed="due === 'today'"
|
||||
@click="filters.toggleDue('today')"
|
||||
>
|
||||
今日到期 {{ dueTodayCount }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-sm transition"
|
||||
:class="
|
||||
due === 'overdue'
|
||||
? 'bg-rose-100 text-rose-700'
|
||||
: overdueCount > 0
|
||||
? 'text-rose-600 hover:bg-slate-200'
|
||||
: 'text-slate-500 hover:bg-slate-200'
|
||||
"
|
||||
:aria-pressed="due === 'overdue'"
|
||||
@click="filters.toggleDue('overdue')"
|
||||
>
|
||||
逾期 {{ overdueCount }}
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-2 py-0.5 text-sm text-slate-500 transition hover:bg-white"
|
||||
aria-label="快捷键帮助"
|
||||
@click="emit('open-help')"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-2.5 py-1 text-sm text-slate-600 transition hover:bg-white"
|
||||
@click="emit('open-backup')"
|
||||
>
|
||||
备份
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref="searchRef"
|
||||
v-model="keyword"
|
||||
type="search"
|
||||
class="w-full max-w-xs rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none placeholder:text-slate-400 focus:border-sky-400"
|
||||
placeholder="搜索标题或描述(/ 聚焦)"
|
||||
aria-label="搜索任务"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="value in PRIORITY_ORDER"
|
||||
:key="value"
|
||||
type="button"
|
||||
class="rounded-md border px-2 py-1 text-xs transition"
|
||||
:class="
|
||||
priorities.includes(value)
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||
"
|
||||
:aria-pressed="priorities.includes(value)"
|
||||
@click="filters.togglePriority(value)"
|
||||
>
|
||||
{{ PRIORITY_LABELS[value] }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
type="button"
|
||||
class="rounded-full border px-2 py-0.5 text-xs transition"
|
||||
:class="
|
||||
tags.includes(tag)
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||
"
|
||||
:aria-pressed="tags.includes(tag)"
|
||||
@click="filters.toggleTag(tag)"
|
||||
>
|
||||
#{{ tag }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="active"
|
||||
type="button"
|
||||
class="rounded-md px-2 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-700 hover:underline"
|
||||
@click="filters.clear()"
|
||||
>
|
||||
清空筛选
|
||||
</button>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
v-if="archivableCount > 0"
|
||||
type="button"
|
||||
class="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-white"
|
||||
@click="emit('archive')"
|
||||
>
|
||||
归档 {{ archivableCount }} 个已完成
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border px-2 py-1 text-xs transition"
|
||||
:class="
|
||||
includeArchived
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||
"
|
||||
:aria-pressed="includeArchived"
|
||||
@click="filters.toggleArchived()"
|
||||
>
|
||||
显示归档
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
Reference in New Issue
Block a user