feat: use swipe to complete items
ci / docker (push) Successful in 3m22s

This commit is contained in:
2026-09-06 08:12:44 +08:00
parent 35e562026c
commit 13715945d5
10 changed files with 69 additions and 25 deletions
+20 -4
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { isTaskView } from './lib/mvp-utils'
import { isTaskView, shouldToggleHabitSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue'
@@ -43,6 +43,7 @@ const totalTasks = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const expandedFolders = ref(new Set<string>())
const navigationLoaded = ref(false)
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
@@ -221,6 +222,21 @@ async function patchTask(task: Task, patch: Partial<Task>) {
async function toggle(task: Task) {
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
}
function startTaskSwipe(task: Task, event: TouchEvent) {
if (activeView.value === 'trash' || loading.value) return
const touch = event.touches[0]
if (touch) taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
}
async function finishTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value
taskSwipeStart.value = null
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
const touch = event.changedTouches[0]
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) await toggle(task)
}
function cancelTaskSwipe() {
taskSwipeStart.value = null
}
async function saveTask() {
if (!selectedTask.value?.title.trim()) return
try {
@@ -341,15 +357,15 @@ onMounted(bootstrap)
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id">
<article class="task-row" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}">
<button v-if="activeView!=='trash'" class="check" :class="`p${node.task.priority}`" aria-label="切换完成状态" @click.stop="toggle(node.task)"><Check v-if="node.task.completed"/></button>
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe">
<button class="task-main" @click="activeView==='trash'?undefined:selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></button>
<span v-if="activeView!=='trash'" class="task-swipe-hint">{{ node.task.completed ? '已完成' : '右滑' }}</span>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask" :class="{done:subtask.completed}"><GripVertical/><button class="check" @click="toggle(subtask)"><Check v-if="subtask.completed"/></button><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button></article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed}" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe"><GripVertical/><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button><span class="task-swipe-hint">{{ subtask.completed ? '已完成' : '右滑' }}</span></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>