diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3187fac..807f494 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@ import { Settings, Trash2, X, Repeat2, RefreshCw, 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 { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-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' @@ -60,6 +60,7 @@ const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_ const activeList = ref(restoredNavigation.listId) const activeView = ref(restoredNavigation.view) const selectedTask = ref(null) +const taskSelectionGeneration = ref(0) const sidebarCreateOpen = ref(false) const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null) const listMoveMenuOpen = ref(false) @@ -137,6 +138,8 @@ const taskReorderTarget = ref('') const taskReorderBlocked = ref(false) const taskReorderMode = ref(false) const taskComposeOpen = ref(false) +const taskComposeGeneration = ref(0) +const creatingTask = ref(false) const composeTitle = ref('') const composeTitleError = ref('') const composeListId = ref('') @@ -161,8 +164,10 @@ const selectedRepeatError = ref('') const selectedTaskRecurrence = ref(null) const recurrenceLoading = ref(false) const savingSelectedTask = ref(false) +const removingTaskId = ref(null) +const addingSubtask = ref(false) const removingSubtaskId = ref(null) -const taskDetailBusy = computed(() => savingSelectedTask.value || recurrenceLoading.value || removingSubtaskId.value !== null) +const taskDetailBusy = computed(() => savingSelectedTask.value || recurrenceLoading.value || removingTaskId.value !== null || addingSubtask.value || removingSubtaskId.value !== null) const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' }) const composeRepeatConfig = ref(defaultRepeatConfig()) const selectedRepeatConfig = ref(defaultRepeatConfig()) @@ -186,6 +191,7 @@ let suppressTaskClickId = '' const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.value.x}px`, '--fab-origin-y': `${composeOrigin.value.y}px` })) function openTaskCompose() { + taskComposeGeneration.value += 1 const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value composeTitle.value = '' composeTitleError.value = '' @@ -222,7 +228,12 @@ function addComposeTime() { try { picker?.showPicker?.() } catch { picker?.focus() } }) } -function closeTaskCompose() { composeCalendarOpen.value = false; taskComposeOpen.value = false } +function closeTaskCompose() { + if (creatingTask.value) return + taskComposeGeneration.value += 1 + composeCalendarOpen.value = false + taskComposeOpen.value = false +} function activateFloatingAdd(origin: { x: number; y: number }) { composeOrigin.value = origin if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin) @@ -244,6 +255,8 @@ async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatCon } async function loadTaskRecurrence(task: Task) { const token = ++recurrenceLoadToken + const selectionGeneration = taskSelectionGeneration.value + const selectionIsCurrent = () => token === recurrenceLoadToken && taskSelectionGeneration.value === selectionGeneration && selectedTask.value?.id === task.id recurrenceLoading.value = true selectedTaskRecurrence.value = null selectedTaskRepeat.value = 'none' @@ -251,19 +264,20 @@ async function loadTaskRecurrence(task: Task) { selectedRepeatError.value = '' try { const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null - if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return + if (!selectionIsCurrent()) return selectedTaskRecurrence.value = recurrence const parsed = parseTaskRecurrence(recurrence) selectedTaskRepeat.value = parsed.option selectedAfterCompletionDays.value = String(parsed.afterCompletionDays) selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig() } catch (reason) { - if (token === recurrenceLoadToken) fail(reason) + if (selectionIsCurrent()) fail(reason) } finally { - if (token === recurrenceLoadToken) recurrenceLoading.value = false + if (selectionIsCurrent()) recurrenceLoading.value = false } } async function submitTaskCompose() { + if (creatingTask.value) return const normalized = normalizeRequiredName(composeTitle.value) if (normalized.error) { composeTitleError.value = normalized.error @@ -273,27 +287,40 @@ async function submitTaskCompose() { composeTitle.value = taskTitle composeTitleError.value = '' if (!composeListId.value) return + const composeGeneration = taskComposeGeneration.value + const targetListId = composeListId.value + creatingTask.value = true try { const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value }) const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : '' if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间') - const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ - title: taskTitle, - list_id: composeListId.value, - due_at: fromDateTimeLocal(dueValue), - due_has_time: composeHasTime.value, - priority: composePriority.value, - description: composeDescription.value, - ...recurrencePayload, - }) }) - if (isTaskView(activeView.value)) { - tasks.value.push(task) - totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value) + await taskMutationReconciler.run( + () => api('/tasks', { method: 'POST', body: JSON.stringify({ + title: taskTitle, + list_id: targetListId, + due_at: fromDateTimeLocal(dueValue), + due_has_time: composeHasTime.value, + priority: composePriority.value, + description: composeDescription.value, + ...recurrencePayload, + }) }) as Promise, + () => { + if (taskComposeGeneration.value === composeGeneration) taskComposeOpen.value = false + toast('任务已添加') + }, + (reason) => { + if (taskComposeGeneration.value !== composeGeneration) return + composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败' + fail(reason) + }, + () => { if (activeView.value === 'today') void loadTodayTaskSummary() }, + ) + } catch (reason) { + if (taskComposeGeneration.value === composeGeneration) { + composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败' + fail(reason) } - if (activeView.value === 'today') void loadTodayTaskSummary() - taskComposeOpen.value = false - toast('任务已添加') - } catch (reason) { composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'; fail(reason) } + } finally { creatingTask.value = false } } function toggleSidebar() { const compact = window.matchMedia('(max-width: 930px)').matches @@ -670,6 +697,7 @@ async function loadTrash() { async function switchView(view: View, listId?: string) { if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return taskMutationNavigation.value += 1 + taskCompletionExitCoordinator.clearAll() taskReorderMode.value = false cancelTaskReorder() if (view !== 'today') { @@ -690,7 +718,7 @@ async function switchView(view: View, listId?: string) { if (listId) activeList.value = listId writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value) page.value = 1 - selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null + selectedTask.value = null; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null if (view !== 'memos') memoDetailOpen.value = false if (view === 'trash') await loadTrash() else if (view === 'today') await loadTodayView() @@ -721,32 +749,104 @@ function applyTaskUpdate(task: Task, updated: Task, updateSelected = true) { } } async function patchTask(task: Task, patch: Partial, updateSelected = true) { - const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) as Task - applyTaskUpdate(task, updated, updateSelected) - return updated + const result: { value?: Task } = {} + let mutationError: unknown + await taskMutationReconciler.run( + () => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify(taskVersionedPatchPayload(task, patch)) }) as Promise, + (updated) => { + result.value = updated + applyTaskUpdate(task, updated, updateSelected) + }, + (reason) => { mutationError = reason }, + ) + if (mutationError) throw mutationError + return result.value ?? false } 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 - const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value }) - await taskMutationReconciler.run( - () => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise, - () => toast(completing ? '完成啦' : '已重新打开'), - fail, - async (updated) => { - if (animateExit) setTaskCompletionExiting(task.id, true) - applyTaskUpdate(task, updated) - if (completing) markTaskJustCompleted(task.id) - if (animateExit) { - await waitForCompletionExit() - setTaskCompletionExiting(task.id, false) +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 +async function reconcileCurrentViewAfterTaskMutation(options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) { + await reconcileCurrentTaskView({ + capture: currentTaskViewContext, + isTaskBacked: context => isTaskView(context.view), + isTrash: context => context.view === 'trash', + sameContext: sameTaskViewContext, + affectsTrash: options.affectsTrash, + affectsTaskView: options.affectsTaskView, + loadTrash: async (ownership) => { + if (ownership.current()) await loadTrash() + }, + loadTaskView: async (context, ownership) => { + const request = beginLatestRequest('tasks') + loading.value = true + error.value = '' + try { + if (context.view === 'today') { + await startPrimaryWithBackground( + [() => loadTasksPage(request), () => loadOverdueTasks(request)], + loadTodayTaskSummary, + ) + } else { + await loadTasksPage(request) + if (ownership.current() && isLatestRequest('tasks', request)) overdueTasks.value = [] + } + if (ownership.current() && isLatestRequest('tasks', request) && page.value > totalPages.value) { + page.value = totalPages.value + await loadTasksPage(request) + } + } catch (reason) { + if (ownership.current() && isLatestRequest('tasks', request)) fail(reason) + } finally { + if (ownership.current() && isLatestRequest('tasks', request)) loading.value = false } - if (activeView.value === 'today' && showCompleted.value) await loadAll() + }, + }) +} +const taskMutationReconciler = createMutationReconciler( + currentTaskViewContext, + sameTaskViewContext, + reconcileCurrentViewAfterTaskMutation, +) +const taskToggleCoordinator = createTaskToggleCoordinator() +const taskCompletionExitCoordinator = createTaskCompletionExitCoordinator(setTaskCompletionExiting) +async function toggle(task: Task) { + taskCompletionExitCoordinator.supersede(task.id) + const preserveSelectedDraft = selectedTask.value?.id === task.id + const navigation = taskMutationNavigation.value + const view = activeView.value + const listId = activeList.value + const selectionGeneration = taskSelectionGeneration.value + const isCurrentToggle = () => taskMutationNavigation.value === navigation && activeView.value === view && activeList.value === listId + await taskToggleCoordinator.toggle( + task.id, + task, + (payload) => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify(payload) }) as Promise, + { + current: isCurrentToggle, + beforeReconcile: async (updated, ownership) => { + if (!ownership.current()) return + const completing = updated.completed + const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value }) + const exitOwnership = taskCompletionExitCoordinator.begin(task.id, animateExit) + applyTaskUpdate(task, updated, !preserveSelectedDraft) + if (preserveSelectedDraft && selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) { + selectedTask.value = mergeTaskToggleResponse(selectedTask.value, updated) + } + if (completing) markTaskJustCompleted(task.id) + await taskCompletionExitCoordinator.wait(task.id, exitOwnership, waitForCompletionExit) + }, + reconcile: async (confirmed, ownership) => { + await reconcileCurrentViewAfterTaskMutation() + if (ownership.current() && preserveSelectedDraft && selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) { + selectedTask.value = mergeTaskToggleResponse(selectedTask.value, confirmed) + } + }, + success: async (updated, ownership) => { + const completing = updated.completed + if (ownership.current()) toast(completing ? '完成啦' : '已重新打开') + }, + error: (_confirmed, reason, ownership) => { if (ownership.current()) fail(reason) }, }, ) } @@ -817,14 +917,15 @@ async function finishTaskReorder(task: Task, event: PointerEvent) { tasks.value = next ids = next.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id) } - try { - await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }) - toast('顺序已保存') - } catch (reason) { - tasks.value = previous - selectedTask.value = previousSelected - fail(reason) - } + await taskMutationReconciler.run( + () => api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }), + () => toast('顺序已保存'), + (reason) => { + tasks.value = previous + selectedTask.value = previousSelected + fail(reason) + }, + ) } function cancelTaskReorder() { taskReorder.value = null @@ -936,6 +1037,7 @@ async function saveTask(options?: { showSuccess?: boolean, expectedTaskId?: stri const due = buildTaskDueDraft({ date: selectedDueDate.value, hasTime: selectedDueHasTime.value, time: selectedDueTime.value }) try { const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), ...due, list_id: task.list_id } as Partial, false) + if (!updated) return false const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken if (selectedTask.value?.id === task.id && selectionMatches) { selectedTask.value = { ...selectedTask.value, ...updated } @@ -982,47 +1084,70 @@ async function saveSelectedTaskChanges() { } } async function removeTask(task: Task) { - if (taskDetailBusy.value) return + if (taskDetailBusy.value || removingTaskId.value === task.id) return if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return if (taskDetailBusy.value || selectedTask.value?.id !== task.id) return + const selectionGeneration = taskSelectionGeneration.value + removingTaskId.value = task.id try { - await api(`/tasks/${task.id}`, { method: 'DELETE' }) - tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id) - selectedTask.value = null - mobileDetail.value = false - if (activeView.value === 'today') void loadTodayTaskSummary() - toast('已移到回收站') - } catch (reason) { fail(reason) } + await taskMutationReconciler.run( + () => api(`/tasks/${task.id}`, { method: 'DELETE' }), + () => { + if (selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) { + selectedTask.value = null + taskSelectionGeneration.value += 1 + mobileDetail.value = false + } + toast('已移到回收站') + }, + fail, + () => { if (activeView.value === 'today') void loadTodayTaskSummary() }, + { affectsTrash: true }, + ) + } finally { if (removingTaskId.value === task.id) removingTaskId.value = null } } -async function mutateTrashTask(task: Task, mutation: () => Promise, successMessage: string) { - const result = await performTrashMutation( +async function mutateTrashTask(task: Task, mutation: () => Promise, successMessage: string, affectsTaskView: boolean) { + await taskMutationReconciler.run( mutation, - () => { - trash.value = trash.value.filter((item) => item.id !== task.id) - totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value) - if (page.value > totalPages.value) page.value = totalPages.value - }, - loadTrash, + () => toast(successMessage), + fail, + undefined, + { affectsTrash: true, affectsTaskView }, ) - if (!result.mutated) { - fail(result.error) - return - } - if (!result.refreshed) toast(`${successMessage},但列表刷新失败,请重试刷新`) - else toast(successMessage) } async function restoreTask(task: Task) { - await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复') + await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true) } async function purgeTask(task: Task) { if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return - await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除') + await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false) } async function addSubtask() { - if (!selectedTask.value) return + if (!selectedTask.value || addingSubtask.value) return + const parent = selectedTask.value + const parentId = parent.id + const parentListId = parent.list_id + const selectionGeneration = taskSelectionGeneration.value const subtaskTitle = (await askText('添加子任务', '子任务名称', '', '添加'))?.trim() - if (!subtaskTitle) return - try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) } + if (!subtaskTitle || addingSubtask.value) return + addingSubtask.value = true + try { + await taskMutationReconciler.run( + () => api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: parentListId, parent_id: parentId }) }) as Promise, + (child) => { + if (selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration) { + selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child] + } + toast('子任务已添加') + }, + (reason) => { + if (selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration) fail(reason) + }, + () => { if (activeView.value === 'today') void loadTodayTaskSummary() }, + ) + } finally { + addingSubtask.value = false + } } async function removeSubtask(subtask: Task) { if (taskDetailBusy.value) return @@ -1030,19 +1155,29 @@ async function removeSubtask(subtask: Task) { if (!parent || subtask.parent_id !== parent.id) return if (!(await confirmAction(`删除子任务“${subtask.title}”?`, '子任务将移到回收站。', true))) return if (taskDetailBusy.value || selectedTask.value?.id !== parent.id) return + const selectionGeneration = taskSelectionGeneration.value removingSubtaskId.value = subtask.id try { - await api(`/tasks/${subtask.id}`, { method: 'DELETE' }) - if (selectedTask.value?.id !== parent.id) return - parent.subtasks = (parent.subtasks ?? []).filter((item) => item.id !== subtask.id) - tasks.value = tasks.value.filter((item) => item.id !== subtask.id) - toast('子任务已删除') - } catch (reason) { fail(reason) } - finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null } + await taskMutationReconciler.run( + () => api(`/tasks/${subtask.id}`, { method: 'DELETE' }), + () => { + if (selectedTask.value?.id === parent.id && taskSelectionGeneration.value === selectionGeneration) { + selectedTask.value.subtasks = (selectedTask.value.subtasks ?? []).filter((item) => item.id !== subtask.id) + } + toast('子任务已删除') + }, + (reason) => { + if (selectedTask.value?.id === parent.id && taskSelectionGeneration.value === selectionGeneration) fail(reason) + }, + undefined, + { affectsTaskView: true }, + ) + } finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null } } function closeTaskDetail() { if (taskDetailBusy.value) return recurrenceLoadToken += 1 + taskSelectionGeneration.value += 1 mobileDetail.value = false selectedTask.value = null } @@ -1059,6 +1194,7 @@ function clearSelectedDueDate() { selectedTaskRepeat.value = 'none' } function selectTask(task: Task) { + taskSelectionGeneration.value += 1 selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] } const due = parseTaskDueDraft(task.due_at, task.due_has_time) selectedDueDate.value = due.date @@ -1676,7 +1812,7 @@ onUnmounted(() => {
每隔