refactor: remove manual refresh and search controls
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m17s

This commit is contained in:
2026-09-19 21:15:23 +08:00
parent 0cf1c49094
commit 4973d56a64
29 changed files with 244 additions and 860 deletions
+19 -141
View File
@@ -2,15 +2,14 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
Settings, Trash2, X, Repeat2, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
import { deriveMemoShellState } from './lib/app-shell-state'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue'
@@ -76,21 +75,9 @@ const listReorderPlacement = ref<'before' | 'after'>('after')
let listHandleLongPressTimer: number | undefined
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
let suppressListClickId = ''
const query = ref('')
const searchInput = ref<HTMLInputElement | null>(null)
const taskSearchToggle = ref<HTMLButtonElement | null>(null)
const mobileSearchOpen = ref(false)
const searchPullDistance = ref(0)
let searchTouchStartY: number | null = null
const taskSearchAvailable = computed(() => ['tasks', 'today', 'upcoming', 'trash'].includes(activeView.value))
const searchRevealStyle = computed(() => ({
'--search-pull': `${searchPullDistance.value}px`,
'--search-pull-opacity': String(Math.min(1, searchPullDistance.value / 56)),
}))
const error = ref('')
const notice = ref('')
const loading = ref(false)
const refreshing = ref(false)
const taskDueNowMs = useTaskDueClock()
const mobileSidebar = ref(false)
const sidebarCollapsed = ref(false)
@@ -116,7 +103,7 @@ const todayEnvironmentLoading = ref(false)
const todayEnvironmentError = ref(false)
const todayEnvironmentDateKey = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)
const taskReorderAvailable = computed(() => activeView.value === 'tasks' && totalPages.value === 1 && taskTree.value.length > 1)
const expandedFolders = ref(new Set<string>())
const justCompletedTaskIds = ref(new Set<string>())
const completionExitingTaskIds = ref(new Set<string>())
@@ -377,22 +364,14 @@ const visibleTasks = computed(() => {
return Boolean(dueToday || completedToday)
})
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8))
return query.value.trim() ? filterTasks(result, query.value) : result
return result
})
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
const taskTree = computed(() => filteredTaskTree.value)
const taskTree = computed(() => groupTaskTree(visibleTasks.value))
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
let searchTimer: number | undefined
watch(composeDueAt, (value) => {
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
})
watch(query, () => {
if (searchTimer) window.clearTimeout(searchTimer)
if (!isTaskView(activeView.value)) return
page.value = 1
searchTimer = window.setTimeout(() => loadAll(), 250)
})
watch(taskReorderAvailable, () => {
if (!taskReorderAvailable.value) taskReorderMode.value = false
})
@@ -571,8 +550,7 @@ async function loadOverdueTasks(request = beginLatestRequest('tasks')) {
}
async function loadTasksPage(request = beginLatestRequest('tasks')) {
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
if (query.value) params.set('q', query.value)
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
if (activeView.value === 'today') {
params.set('due_from', isoAtLocalDayOffset(0))
params.set('due_to', isoAtLocalDayOffset(1))
@@ -587,9 +565,9 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
if (!isLatestRequest('tasks', request)) return
tasks.value = data.items ?? []
totalTasks.value = data.total ?? tasks.value.length
if (activeView.value === 'upcoming' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && (showCompleted.value || query.value)) {
if (activeView.value === 'upcoming' && !showCompleted.value) taskOpenTotal.value = totalTasks.value
if (activeView.value === 'tasks' && !showCompleted.value) taskOpenTotal.value = totalTasks.value
if ((activeView.value === 'tasks' || activeView.value === 'upcoming') && showCompleted.value) {
const token = ++taskOpenTotalLoadToken
const listId = activeList.value
const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' })
@@ -603,7 +581,7 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) {
)
}
hiddenCompletedTaskCount.value = 0
if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) {
if (!showCompleted.value && activeView.value !== 'trash' && tasks.value.length === 0) {
const completedParams = new URLSearchParams(params)
completedParams.set('page', '1')
completedParams.set('page_size', '1')
@@ -671,18 +649,6 @@ async function refreshAll() {
navigationLoaded.value = false
await loadAll()
}
async function refreshCurrentView() {
if (refreshing.value || loading.value) return
refreshing.value = true
try {
if (activeView.value === 'habits') await habitComposer.value?.refreshHabits()
else if (activeView.value === 'settings') await habitComposer.value?.refreshSettings()
else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()])
else await refreshAll()
} finally {
refreshing.value = false
}
}
type TrashPage = { items?: Task[]; total?: number }
async function loadTrashPage() {
return api(`/trash?page=${page.value}&page_size=${pageSize}`) as Promise<TrashPage>
@@ -712,8 +678,6 @@ async function switchView(view: View, listId?: string) {
activeView.value = view
taskOpenTotal.value = null
++taskOpenTotalLoadToken
if (!query.value) mobileSearchOpen.value = false
searchPullDistance.value = 0
if (view !== 'trash') beginLatestRequest('trash')
if (!isTaskView(view)) {
beginLatestRequest('tasks')
@@ -768,9 +732,9 @@ async function patchTask(task: Task, patch: Partial<Task>, updateSelected = true
return result.value ?? false
}
const taskMutationNavigation = ref(0)
type TaskViewContext = { navigation: number; view: View; listId: string; page: number; query: string; showCompleted: boolean }
const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value })
const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted
type TaskViewContext = { navigation: number; view: View; listId: string; page: number; showCompleted: boolean }
const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, showCompleted: showCompleted.value })
const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.showCompleted === right.showCompleted
async function reconcileCurrentViewAfterTaskMutation(options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) {
await reconcileCurrentTaskView({
capture: currentTaskViewContext,
@@ -1509,76 +1473,6 @@ function nextPage() {
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
}
function isMobileSearchLayout() {
return window.matchMedia('(max-width: 930px)').matches
}
function openTaskSearch() {
if (!taskSearchAvailable.value) return
if (isMobileSearchLayout()) mobileSearchOpen.value = true
searchPullDistance.value = 0
void nextTick(() => searchInput.value?.focus())
}
function closeTaskSearch() {
if (!isMobileSearchLayout()) return
mobileSearchOpen.value = false
searchPullDistance.value = 0
void nextTick(() => taskSearchToggle.value?.focus())
}
function toggleTaskSearch() {
if (mobileSearchOpen.value) closeTaskSearch()
else openTaskSearch()
}
function handleTaskSearchKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !isMobileSearchLayout()) return
event.preventDefault()
closeTaskSearch()
}
function startSearchPull(event: TouchEvent) {
if (!taskSearchAvailable.value || !isMobileSearchLayout()) return
const target = event.target as Element | null
if (target?.closest('input, textarea, select, button, .task-row, .habit-row, .countdown-row')) return
const main = event.currentTarget as HTMLElement
const shell = main.closest('.shell') as HTMLElement | null
if (!isAtSearchPullOrigin(main.scrollTop, shell?.scrollTop ?? 0, window.scrollY) || event.touches.length !== 1) return
searchTouchStartY = event.touches[0].clientY
searchPullDistance.value = 0
}
function moveSearchPull(event: TouchEvent) {
if (searchTouchStartY === null || event.touches.length !== 1) return
const deltaY = event.touches[0].clientY - searchTouchStartY
if (mobileSearchOpen.value) {
searchPullDistance.value = Math.min(0, deltaY)
if (deltaY < 0 && !query.value.trim()) event.preventDefault()
return
}
searchPullDistance.value = clampSearchPullDistance(deltaY)
if (deltaY > 0) event.preventDefault()
}
function finishSearchPull(event: TouchEvent) {
if (searchTouchStartY === null) return
const endY = event.changedTouches[0]?.clientY ?? searchTouchStartY
const deltaY = endY - searchTouchStartY
searchTouchStartY = null
if (!mobileSearchOpen.value && shouldRevealSearchAfterPull(deltaY)) openTaskSearch()
else if (mobileSearchOpen.value && shouldHideSearchAfterSwipe(deltaY, query.value)) {
mobileSearchOpen.value = false
searchInput.value?.blur()
}
searchPullDistance.value = 0
}
function cancelSearchPull() {
searchTouchStartY = null
searchPullDistance.value = 0
}
function handleTaskSearchShortcut(event: KeyboardEvent) {
if (!isSearchShortcut(event) || !taskSearchAvailable.value) return
event.preventDefault()
openTaskSearch()
}
function handleSearchBlur() {
if (!query.value.trim()) mobileSearchOpen.value = false
}
function handleViewportResize() {
compactLayout.value = window.innerWidth <= 930
handleArchivedListViewportChange()
@@ -1587,7 +1481,6 @@ function handleViewportResize() {
onMounted(() => {
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
document.addEventListener('keydown', handleArchivedListEscape)
document.addEventListener('keydown', handleTaskSearchShortcut)
window.addEventListener('resize', handleViewportResize)
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
window.addEventListener('focus', handleTodayEnvironmentResume)
@@ -1598,7 +1491,6 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
document.removeEventListener('keydown', handleArchivedListEscape)
document.removeEventListener('keydown', handleTaskSearchShortcut)
window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
window.removeEventListener('focus', handleTodayEnvironmentResume)
@@ -1690,19 +1582,10 @@ onUnmounted(() => {
</AppSheet>
</aside>
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
</div>
<button v-if="activeView==='settings'" class="icon topbar-refresh settings-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新设置" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
<div v-if="taskSearchAvailable && !['tasks','upcoming'].includes(activeView)" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-search-panel" @click="toggleTaskSearch"><Search/></button>
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
<label id="task-search-panel" class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
</div>
</header>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @update:show-completed="showCompleted=$event" @changed="refreshAll" @notice="toast" />
@@ -1721,11 +1604,6 @@ onUnmounted(() => {
<div><h1 class="list-page-title" :title="activeName">{{ activeName }}</h1><p class="list-page-summary">{{ taskOpenTotal === null ? '待完成统计暂不可用' : `还有 ${taskOpenTotal} 项待完成` }}</p></div>
<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />
</section>
<div v-if="activeView==='tasks' || activeView==='upcoming'" class="list-search-reveal" :class="{'mobile-search-open':mobileSearchOpen}">
<label id="task-list-search-panel" class="list-search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
<button v-if="query" class="list-search-clear" type="button" @click="query=''">清除搜索</button>
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-list-search-panel" @click="toggleTaskSearch"><Search/></button>
</div>
<template v-if="activeView==='today'">
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '' : '⌄' }}</span></button>
@@ -1738,20 +1616,20 @@ onUnmounted(() => {
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '' : '⌄' }}</span></button>
</template>
<div v-if="activeView==='tasks' || activeView==='upcoming'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="activeView==='tasks' && taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<div v-if="activeView==='trash'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></div>
<div v-if="activeView==='tasks' && totalPages > 1" class="list-page-meta"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</span></div>
<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 :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : (activeView==='tasks' || activeView==='upcoming') ? 'task-list-title' : undefined">
<template v-for="node in taskTree" :key="node.task.id">
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'task-row--trash':activeView==='trash','just-completed': justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),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="taskReorderMode" 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="taskReorderMode" class="drag-handle task-drag-handle" :disabled="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="activeView==='trash' ? undefined : 'button'" :tabindex="activeView==='trash' ? undefined : 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 :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span>
</article>
</template>
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
<div v-if="activeView==='today' && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
<div v-else-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span></div>
</section>
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button>