This commit is contained in:
+11
-3
@@ -49,6 +49,7 @@ const pageSize = 50
|
||||
const totalTasks = ref(0)
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
||||
const expandedFolders = ref(new Set<string>())
|
||||
const collapsedTaskIds = ref(new Set<string>())
|
||||
const navigationLoaded = ref(false)
|
||||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
@@ -554,11 +555,18 @@ function cancelTaskSwipe(task?: Task) {
|
||||
taskSwipeStart.value = null
|
||||
if (task) taskSwipeOffsets.value[task.id] = 0
|
||||
}
|
||||
function selectTaskUnlessSwiped(task: Task) {
|
||||
function toggleTaskChildren(task: Task) {
|
||||
if (!task.subtasks?.length) return
|
||||
const next = new Set(collapsedTaskIds.value)
|
||||
next.has(task.id) ? next.delete(task.id) : next.add(task.id)
|
||||
collapsedTaskIds.value = next
|
||||
}
|
||||
function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
|
||||
if (suppressTaskClickId === task.id) {
|
||||
suppressTaskClickId = ''
|
||||
return
|
||||
}
|
||||
if (toggleChildren) toggleTaskChildren(task)
|
||||
selectTask(task)
|
||||
}
|
||||
async function saveTask() {
|
||||
@@ -725,13 +733,13 @@ onMounted(bootstrap)
|
||||
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
||||
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
||||
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
|
||||
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
|
||||
<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" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article>
|
||||
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article>
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
||||
</section>
|
||||
|
||||
@@ -8,6 +8,7 @@ type SearchTask = {
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
list_name?: string
|
||||
subtasks?: SearchTask[]
|
||||
}
|
||||
|
||||
const tasks: SearchTask[] = [
|
||||
@@ -26,6 +27,12 @@ describe('task utilities', () => {
|
||||
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
|
||||
})
|
||||
|
||||
it('preserves subtasks already nested by the task API', () => {
|
||||
const child: SearchTask = { id: 'nested-child', title: 'Nested child', parent_id: 'nested-parent' }
|
||||
const parent: SearchTask = { id: 'nested-parent', title: 'Nested parent', parent_id: null, subtasks: [child] }
|
||||
expect(groupTaskTree([parent])).toEqual([{ task: parent, subtasks: [child] }])
|
||||
})
|
||||
|
||||
it('moves an item before or after another item without changing other scopes', () => {
|
||||
const rows = [
|
||||
{ id: 'a', parent_id: null },
|
||||
|
||||
@@ -5,6 +5,7 @@ export type MinimalTask = {
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
list_name?: string
|
||||
subtasks?: MinimalTask[]
|
||||
}
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
@@ -85,7 +86,10 @@ export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
|
||||
if (!task.parent_id) continue
|
||||
children.set(task.parent_id, [...(children.get(task.parent_id) ?? []), task])
|
||||
}
|
||||
return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] }))
|
||||
return tasks.filter((task) => !task.parent_id).map((task) => ({
|
||||
task,
|
||||
subtasks: children.get(task.id) ?? (task.subtasks as T[] | undefined) ?? [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function moveItemWithinScope<T extends { id: string; parent_id?: string | null }>(items: T[], sourceId: string, targetId: string, placement: 'before' | 'after') {
|
||||
|
||||
@@ -82,6 +82,14 @@ describe('task and habit row decoration', () => {
|
||||
expect(mvpPanel).toContain('shouldToggleRowSwipe')
|
||||
})
|
||||
|
||||
it('collapses and expands subtasks when the parent task is clicked', () => {
|
||||
expect(app).toContain('const collapsedTaskIds = ref(new Set<string>())')
|
||||
expect(app).toContain('function toggleTaskChildren(task: Task)')
|
||||
expect(app).toContain('selectTaskUnlessSwiped(node.task, true)')
|
||||
expect(app).toContain('v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks"')
|
||||
expect(app).toContain(':aria-expanded="!collapsedTaskIds.has(node.task.id)"')
|
||||
})
|
||||
|
||||
it('shows visible completion buttons for tasks and subtasks while keeping swipe shortcuts', () => {
|
||||
expect(app).not.toContain('class="sr-only" :aria-label="node.task.completed')
|
||||
expect(app).not.toContain('class="sr-only" :aria-label="subtask.completed')
|
||||
|
||||
Reference in New Issue
Block a user