fix: harden async task and habit mutations
This commit is contained in:
+225
-89
@@ -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<View>(restoredNavigation.view)
|
||||
const selectedTask = ref<Task | null>(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<Recurrence | null>(null)
|
||||
const recurrenceLoading = ref(false)
|
||||
const savingSelectedTask = ref(false)
|
||||
const removingTaskId = ref<string | null>(null)
|
||||
const addingSubtask = ref(false)
|
||||
const removingSubtaskId = ref<string | null>(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<TaskRepeatConfig>(defaultRepeatConfig())
|
||||
const selectedRepeatConfig = ref<TaskRepeatConfig>(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<Task>,
|
||||
() => {
|
||||
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<Task>, 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<Task>,
|
||||
(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<Task>,
|
||||
() => 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<Task>()
|
||||
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<Task>,
|
||||
{
|
||||
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<Task>, 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<unknown>, successMessage: string) {
|
||||
const result = await performTrashMutation(
|
||||
async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, 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<Task>,
|
||||
(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(() => {
|
||||
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
|
||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="creatingTask" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="creatingTask || !composeTitle.trim() || !composeListId">{{ creatingTask ? '正在添加…' : '添加任务' }}</button></footer>
|
||||
</AppSheet>
|
||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||
|
||||
Reference in New Issue
Block a user