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