diff --git a/backend/main.py b/backend/main.py index 170172c..f97f897 100644 --- a/backend/main.py +++ b/backend/main.py @@ -466,7 +466,11 @@ async def list_tasks( q: str | None = None, list_id: UUID | None = None, completed: bool | None = None, + due_from: datetime | None = None, + due_to: datetime | None = None, cursor: str | None = None, + page: int | None = Query(default=None, ge=1), + page_size: int | None = Query(default=None, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), @@ -479,6 +483,10 @@ async def list_tasks( query = query.where(Task.list_id == list_id) if completed is not None: query = query.where(Task.completed.is_(completed)) + if due_from is not None: + query = query.where(Task.due_at >= due_from) + if due_to is not None: + query = query.where(Task.due_at < due_to) if q: pattern = f"%{q}%" tag_match = exists( @@ -496,16 +504,22 @@ async def list_tasks( query = query.where( or_(Task.title.ilike(pattern), Task.description.ilike(pattern), tag_match, list_match) ) + total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 + ordering = (Task.created_at, Task.id) + if page is not None: + size = page_size or limit + items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) + return TaskPage(items=items, total=total, page=page, page_size=size) if cursor: created_at, task_id = _decode_cursor(cursor) query = query.where( or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) ) - rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).limit(limit + 1))).all()) + rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None - return TaskPage(items=items, next_cursor=next_cursor) + return TaskPage(items=items, next_cursor=next_cursor, total=total, page=1, page_size=limit) async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut: @@ -629,6 +643,8 @@ async def delete_task( @app.get("/api/v1/trash", response_model=TaskPage) async def list_trash( cursor: str | None = None, + page: int | None = Query(default=None, ge=1), + page_size: int | None = Query(default=None, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), @@ -636,16 +652,22 @@ async def list_trash( query = select(Task).where( Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None) ) + total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 + ordering = (Task.created_at, Task.id) + if page is not None: + size = page_size or limit + items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) + return TaskPage(items=items, total=total, page=page, page_size=size) if cursor: created_at, task_id = _decode_cursor(cursor) query = query.where( or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) ) - rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).limit(limit + 1))).all()) + rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None - return TaskPage(items=items, next_cursor=next_cursor) + return TaskPage(items=items, next_cursor=next_cursor, total=total, page=1, page_size=limit) @app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut) diff --git a/backend/schemas.py b/backend/schemas.py index a41c934..6adecc6 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -108,6 +108,9 @@ class TaskDetailOut(TaskOut): class TaskPage(BaseModel): items: list[TaskOut] next_cursor: str | None = None + total: int = 0 + page: int = 1 + page_size: int = 50 class BatchTaskUpdate(BaseModel): diff --git a/frontend/src/App.vue b/frontend/src/App.vue index efafe79..e7d872e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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()) 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('.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) @@ -291,7 +324,8 @@ onMounted(bootstrap)