fix: harden async task and habit mutations
ci / gitleaks (push) Successful in 1m50s
ci / docker (push) Successful in 9m26s

This commit is contained in:
2026-09-19 10:00:23 +08:00
parent 0baedbc99e
commit cec0a4418c
8 changed files with 1593 additions and 164 deletions
+225 -89
View File
@@ -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>