feat: add repeating tasks
ci / docker (push) Successful in 4m13s

This commit is contained in:
2026-09-08 08:16:57 +08:00
parent fde37de0fc
commit 0a3b959cb8
7 changed files with 179 additions and 10 deletions
+64 -1
View File
@@ -15,6 +15,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] }
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
type Recurrence = { id: string; task_id: string; rrule: string }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
const initialized = ref<boolean | null>(null)
@@ -58,6 +60,10 @@ const composeListId = ref('')
const composeDueAt = ref('')
const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
let recurrenceLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
@@ -72,6 +78,7 @@ function openTaskCompose() {
composeDueAt.value = activeView.value === 'today' ? toDateTimeLocal(quickTaskFields('today', activeList.value, inboxId).due_at ?? null) : ''
composePriority.value = 0
composeDescription.value = ''
composeRepeat.value = 'none'
taskComposeOpen.value = true
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
}
@@ -83,6 +90,47 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
function repeatRrule(value: RepeatOption) {
return value === 'none' ? '' : `FREQ=${value.toUpperCase()}`
}
function repeatOption(rrule?: string): RepeatOption {
const value = /FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/.exec(rrule ?? '')?.[1]?.toLowerCase()
return (value as RepeatOption | undefined) ?? 'none'
}
async function saveRepeat(task: Task, value: RepeatOption) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
return
}
const rrule = repeatRrule(value)
if (selectedTaskRecurrence.value) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) })
selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule }
} else {
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) })
}
selectedTaskRepeat.value = value
}
async function loadTaskRecurrence(task: Task) {
const token = ++recurrenceLoadToken
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
selectedTaskRecurrence.value = recurrence
selectedTaskRepeat.value = repeatOption(recurrence?.rrule)
} catch (reason) { if (token === recurrenceLoadToken) fail(reason) }
}
async function updateSelectedTaskRepeat() {
if (!selectedTask.value) return
try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) }
}
async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim()
if (!taskTitle || !composeListId.value) return
@@ -94,6 +142,7 @@ async function submitTaskCompose() {
priority: composePriority.value,
description: composeDescription.value,
}) })
if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value) }) })
if (isTaskView(activeView.value)) {
tasks.value.push(task)
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
@@ -485,7 +534,18 @@ async function saveTask() {
if (!selectedTask.value?.title.trim()) return
try {
const task = selectedTask.value
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id } as Partial<Task>)
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial<Task>)
if (selectedTaskRecurrence.value) {
if (dueAt) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) })
} else {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
}
}
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
toast('已保存')
} catch (reason) { fail(reason) }
}
@@ -509,6 +569,7 @@ async function addSubtask() {
function selectTask(task: Task) {
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
void loadTaskRecurrence(task)
}
async function createFolder() {
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
@@ -642,6 +703,7 @@ onMounted(bootstrap)
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
<label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
<label>重复<select v-model="selectedTaskRepeat" :disabled="!selectedTask.due_at" @change="updateSelectedTaskRepeat"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
@@ -663,6 +725,7 @@ onMounted(bootstrap)
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></label>
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3"></option></select></label></div>
<label>截止时间<input v-model="composeDueAt" type="datetime-local"></label>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
<footer><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
</form>