+64
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user