perf: streamline view data loading
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-10 08:06:52 +08:00
parent a5ece8ca42
commit 98578b4f36
7 changed files with 545 additions and 75 deletions
+72 -33
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
@@ -460,13 +460,14 @@ async function loadTodayTaskSummary() {
todayTaskTotal.value = overdue + open + completed
} catch { /* 概览统计失败不阻断今天页 */ }
}
async function loadOverdueTasks() {
async function loadOverdueTasks(request = beginLatestRequest('tasks')) {
const params = new URLSearchParams()
params.set('due_to', isoAtLocalDayOffset(0))
params.set('completed', 'false')
overdueTasks.value = await loadTaskPages(`/tasks?${params}`)
const loaded = await loadTaskPages(`/tasks?${params}`)
if (isLatestRequest('tasks', request)) overdueTasks.value = loaded
}
async function loadTasksPage() {
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)
@@ -474,6 +475,7 @@ async function loadTasksPage() {
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}`)
if (!isLatestRequest('tasks', request)) return
tasks.value = data.items ?? []
totalTasks.value = data.total ?? tasks.value.length
hiddenCompletedTaskCount.value = 0
@@ -483,6 +485,7 @@ async function loadTasksPage() {
completedParams.set('page_size', '1')
completedParams.set('completed', 'true')
const completedData = await api(`/tasks?${completedParams}`)
if (!isLatestRequest('tasks', request)) return
hiddenCompletedTaskCount.value = Number(completedData.total ?? completedData.items?.length ?? 0)
}
}
@@ -499,39 +502,67 @@ async function loadNavigation(force = false) {
}
async function preloadCountdowns() {
try {
const active = await api('/countdowns')
writeCountdownCache(active ?? [], [])
const archived = await api('/countdowns?archived=true')
writeCountdownCache(active ?? [], archived ?? [])
await loadCountdownCache(async () => {
const [active, archived] = await Promise.all([api('/countdowns'), api('/countdowns?archived=true')])
return { items: active ?? [], archived: archived ?? [] }
})
} catch { /* 倒数日页会在打开时重试 */ }
}
async function loadAll() {
loading.value = true; error.value = ''
const request = beginLatestRequest('tasks')
loading.value = true
error.value = ''
try {
if (!navigationLoaded.value) await loadNavigation()
await loadTasksPage()
if (!isLatestRequest('tasks', request)) return
if (activeView.value === 'today') {
await loadOverdueTasks()
void loadTodayTaskSummary()
} else overdueTasks.value = []
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
} catch (reason) { fail(reason) } finally { loading.value = false }
await startPrimaryWithBackground(
[() => loadTasksPage(request), () => loadOverdueTasks(request)],
loadTodayTaskSummary,
)
} else {
await loadTasksPage(request)
if (isLatestRequest('tasks', request)) overdueTasks.value = []
}
if (isLatestRequest('tasks', request) && page.value > totalPages.value) {
page.value = totalPages.value
await loadTasksPage(request)
}
} catch (reason) {
if (isLatestRequest('tasks', request)) fail(reason)
} finally {
if (isLatestRequest('tasks', request)) loading.value = false
}
}
async function refreshAll() {
navigationLoaded.value = false
await loadAll()
}
type TrashPage = { items?: Task[]; total?: number }
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
return api(`/trash?page=${page.value}&page_size=${pageSize}`) as Promise<TrashPage>
}
async function loadTrash() {
loading.value = true; error.value = ''
try { await loadTrashPage() } catch (reason) { fail(reason) } finally { loading.value = false }
loading.value = true
error.value = ''
await runLatestRequest('trash', loadTrashPage, {
success: (data) => {
trash.value = data.items ?? []
totalTasks.value = data.total ?? trash.value.length
},
error: fail,
finally: () => { loading.value = false },
})
}
async function switchView(view: View, listId?: string) {
taskMutationNavigation.value += 1
activeView.value = view
if (view !== 'trash') beginLatestRequest('trash')
if (!isTaskView(view)) {
beginLatestRequest('tasks')
loading.value = false
error.value = ''
}
if (listId) activeList.value = listId
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
page.value = 1
@@ -547,8 +578,7 @@ async function switchView(view: View, listId?: string) {
async function loadTodayView() {
await loadAll()
}
async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
function applyTaskUpdate(task: Task, updated: Task) {
const index = tasks.value.findIndex((item) => item.id === task.id)
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
@@ -557,20 +587,29 @@ async function patchTask(task: Task, patch: Partial<Task>) {
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
}
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
return updated as Task
}
async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) as Task
applyTaskUpdate(task, updated)
return updated
}
const taskMutationNavigation = ref(0)
const taskMutationReconciler = createMutationReconciler(
() => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value }),
(left, right) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted,
loadAll,
)
async function toggle(task: Task) {
const completing = !task.completed
try {
await patchTask(task, { completed: completing })
if (completing) markTaskJustCompleted(task.id)
await loadTasksPage()
if (activeView.value === 'today') {
await loadOverdueTasks()
void loadTodayTaskSummary()
}
toast(task.completed ? '已重新打开' : '完成啦')
} catch (reason) { fail(reason) }
await taskMutationReconciler.run(
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
() => toast(completing ? '完成啦' : '已重新打开'),
fail,
(updated) => {
applyTaskUpdate(task, updated)
if (completing) markTaskJustCompleted(task.id)
},
)
}
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))