feat: add task list pagination
ci / docker (push) Successful in 3m40s

This commit is contained in:
2026-09-05 16:20:13 +08:00
parent 3bf8c7c3d9
commit 9e378b1fbf
5 changed files with 126 additions and 17 deletions
+47 -13
View File
@@ -36,6 +36,10 @@ const mobileSidebar = ref(false)
const mobileDetail = ref(false)
const markdownPreview = ref(false)
const showCompleted = ref(false)
const page = ref(1)
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 activeName = computed(() => {
@@ -52,15 +56,12 @@ const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (query.value.trim()) return result
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value)
if (['calendar','habits','settings'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
if (!showCompleted.value && activeView.value !== 'trash') result = result.filter((task) => !task.completed)
return result
return query.value.trim() ? filterTasks(result, query.value) : result
})
const filteredTaskTree = computed(() => groupTaskTree(filterTasks(visibleTasks.value, query.value)))
const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
const taskTree = computed(() => filteredTaskTree.value)
const selectedCount = computed(() => selectedIds.value.size)
const parentTasks = computed(() => tasks.value.filter((task) => !task.parent_id))
@@ -68,10 +69,11 @@ let searchTimer: number | undefined
watch(query, () => {
if (searchTimer) window.clearTimeout(searchTimer)
if (activeView.value === 'trash') return
page.value = 1
searchTimer = window.setTimeout(() => loadAll(), 250)
})
watch(showCompleted, () => {
if (activeView.value !== 'trash') loadAll()
if (activeView.value !== 'trash') { page.value = 1; loadAll() }
})
async function api(path: string, options: RequestInit = {}) {
@@ -125,6 +127,23 @@ async function loadTaskPages(path: string) {
} while (cursor)
return rows
}
function isoAtLocalDayOffset(offset: number) {
const day = new Date()
day.setDate(day.getDate() + offset)
day.setHours(0, 0, 0, 0)
return day.toISOString()
}
async function loadTasksPage() {
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 === 'today') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(1)) }
if (activeView.value === 'upcoming') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(8)) }
if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false')
const data = await api(`/tasks?${params}`)
tasks.value = data.items ?? []
totalTasks.value = data.total ?? tasks.value.length
}
async function loadAll() {
loading.value = true; error.value = ''
try {
@@ -133,20 +152,24 @@ async function loadAll() {
])
folders.value = folderData; lists.value = listData; tags.value = tagData
activeList.value ||= lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
const params = new URLSearchParams()
if (query.value) params.set('q', query.value)
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false')
tasks.value = await loadTaskPages(`/tasks${params.size ? `?${params}` : ''}`)
await loadTasksPage()
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
} catch (reason) { fail(reason) } finally { loading.value = false }
}
async function loadTrashPage() {
const data = await api(`/trash?page=${page.value}&page_size=${pageSize}`)
trash.value = data.items ?? []
totalTasks.value = data.total ?? trash.value.length
}
async function loadTrash() {
try { trash.value = await loadTaskPages('/trash') } catch (reason) { fail(reason) }
loading.value = true; error.value = ''
try { await loadTrashPage() } catch (reason) { fail(reason) } finally { loading.value = false }
}
async function switchView(view: View, listId?: string) {
activeView.value = view
if (listId) activeList.value = listId
page.value = 1
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false; mobileDetail.value = false
if (view === 'trash') await loadTrash()
else await loadAll()
@@ -242,6 +265,16 @@ function toggleTag(tag: Tag) {
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function formatDue(value: string | null) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>('.quick-input')?.focus()) }
function previousPage() {
if (page.value <= 1 || loading.value) return
page.value -= 1
activeView.value === 'trash' ? loadTrash() : loadAll()
}
function nextPage() {
if (page.value >= totalPages.value || loading.value) return
page.value += 1
activeView.value === 'trash' ? loadTrash() : loadAll()
}
onMounted(bootstrap)
</script>
@@ -291,7 +324,8 @@ onMounted(bootstrap)
</template>
<template v-else>
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>{{visibleTasks.length}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>第 {{page}} / {{totalPages}} 页 · 共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></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>
<div v-if="selectedCount" class="batch-bar"><b>已选 {{selectedCount}} 项</b><button @click="batch('complete')"><Check/>完成</button><button @click="batch('move')"><Folder/>移动</button><button class="danger" @click="batch('delete')"><Trash2/>删除</button><button class="icon" aria-label="取消选择" @click="selectedIds=new Set()"><X/></button></div>
<section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id">
+1
View File
@@ -12,3 +12,4 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.calendar-card,.habit-card,.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.fc{--fc-button-bg-color:var(--accent);--fc-button-border-color:var(--accent);--fc-button-hover-bg-color:#cf461d;--fc-today-bg-color:#fff3eb;font-size:13px}.fc .fc-toolbar-title{font-size:18px}.fc .fc-event{border-color:var(--accent);background:var(--accent);cursor:grab}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.habit-title{display:flex;justify-content:space-between;align-items:start}.habit-title h3,.tool-card h3{margin:0 0 4px}.habit-title small{color:var(--muted)}.week-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;margin-top:13px}.week-grid>div{display:grid;place-items:center;gap:5px;text-align:center}.week-grid small{color:var(--muted);font-size:10px}.habit-check{width:34px;height:34px;border-radius:50%;border:1px solid var(--line);background:#faf7f0}.habit-check.done{background:var(--accent);border-color:var(--accent);color:#fff}.week-grid input{width:100%;min-width:0;border:1px solid var(--line);border-radius:7px;padding:7px;text-align:center}.empty-panel{text-align:center;color:var(--muted)}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
@media(max-width:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.fc .fc-toolbar{align-items:flex-start}.fc .fc-toolbar-title{font-size:16px}.calendar-card{padding:8px}.week-grid{gap:3px}.habit-card{padding:12px}.habit-check{width:30px;height:30px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}