feat: recurring tasks, habits, attachments, import/export, audit and security
This commit is contained in:
+23
-9
@@ -2,16 +2,17 @@
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
|
||||
GripVertical, Inbox, ListChecks, ListTodo, Menu, MoreHorizontal, Pencil, Plus, Search,
|
||||
Settings, Tag as TagIcon, Trash2, X,
|
||||
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||||
Settings, Trash2, X, CalendarRange, Repeat2,
|
||||
} from 'lucide-vue-next'
|
||||
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
|
||||
type FolderItem = { id: string; name: string }
|
||||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||||
type Tag = { id: string; name: string; color: string }
|
||||
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; tags?: Tag[]; subtasks?: Task[] }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash'
|
||||
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; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'calendar' | 'habits' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
const authenticated = ref(false)
|
||||
@@ -41,6 +42,9 @@ const activeName = computed(() => {
|
||||
if (activeView.value === 'trash') return '回收站'
|
||||
if (activeView.value === 'today') return '今天'
|
||||
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||
if (activeView.value === 'calendar') return '月历'
|
||||
if (activeView.value === 'habits') return '习惯'
|
||||
if (activeView.value === 'settings') return '设置与数据'
|
||||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||||
})
|
||||
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
|
||||
@@ -50,6 +54,7 @@ const visibleTasks = computed(() => {
|
||||
let result = sourceTasks.value
|
||||
if (query.value.trim()) return result
|
||||
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value)
|
||||
if (['calendar','habits','settings'].includes(activeView.value)) return []
|
||||
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
||||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||||
if (!showCompleted.value && activeView.value !== 'trash') result = result.filter((task) => !task.completed)
|
||||
@@ -135,7 +140,7 @@ async function loadTrash() {
|
||||
async function switchView(view: View, listId?: string) {
|
||||
activeView.value = view
|
||||
if (listId) activeList.value = listId
|
||||
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false
|
||||
selectedTask.value = null; selectedIds.value = new Set(); mobileSidebar.value = false; mobileDetail.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
}
|
||||
async function addTask() {
|
||||
@@ -159,7 +164,7 @@ 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, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
|
||||
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, recurrence_rule: task.recurrence_rule || null, recurrence_end_at: task.recurrence_end_at || null, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
|
||||
toast('已保存')
|
||||
} catch (reason) { fail(reason) }
|
||||
}
|
||||
@@ -252,6 +257,8 @@ onMounted(bootstrap)
|
||||
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
|
||||
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
||||
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 天</button>
|
||||
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarRange />月历</button>
|
||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
</nav>
|
||||
<div class="section-title"><span>我的清单</span><span><button class="mini-icon" aria-label="新建文件夹" @click="createFolder"><Folder /></button><button class="mini-icon" aria-label="新建清单" @click="createList(null)"><Plus /></button></span></div>
|
||||
@@ -262,7 +269,7 @@ onMounted(bootstrap)
|
||||
</div>
|
||||
<button v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span><span class="row-actions"><button aria-label="重命名清单" @click.stop="renameEntity('lists',list)"><Pencil/></button><button aria-label="删除清单" @click.stop="deleteEntity('lists',list)"><Trash2/></button></span></button>
|
||||
</div>
|
||||
<button class="settings"><Settings />设置</button>
|
||||
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
@@ -271,6 +278,10 @@ onMounted(bootstrap)
|
||||
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
|
||||
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||
</header>
|
||||
<template v-if="['calendar','habits','settings'].includes(activeView)">
|
||||
<MvpPanel :key="activeView" :view="activeView as 'calendar'|'habits'|'settings'" :tasks="tasks" @changed="loadAll" @notice="toast" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
||||
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>{{visibleTasks.length}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
||||
<div v-if="selectedCount" class="batch-bar"><b>已选 {{selectedCount}} 项</b><button @click="batch('complete')"><Check/>完成</button><button @click="batch('move')"><Folder/>移动</button><button class="danger" @click="batch('delete')"><Trash2/>删除</button><button class="icon" aria-label="取消选择" @click="selectedIds=new Set()"><X/></button></div>
|
||||
@@ -289,6 +300,7 @@ onMounted(bootstrap)
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<aside class="detail" :class="{open:mobileDetail}">
|
||||
@@ -298,6 +310,8 @@ onMounted(bootstrap)
|
||||
<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.number="selectedTask.priority" @change="saveTask"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>
|
||||
<label>重复<select v-model="selectedTask.recurrence_rule" @change="saveTask"><option value="">不重复</option><option value="FREQ=DAILY">每天</option><option value="FREQ=WEEKLY">每周</option><option value="FREQ=MONTHLY">每月</option></select></label>
|
||||
<label v-if="selectedTask.recurrence_rule">重复截止<input v-model="selectedTask.recurrence_end_at" type="date" @change="saveTask"></label>
|
||||
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></div>
|
||||
<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 tasks.filter(t=>t.parent_id===selectedTask?.id)" :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="!tasks.some(t=>t.parent_id===selectedTask?.id)" class="hint">把这件事拆成更小的步骤</span></div>
|
||||
@@ -306,8 +320,8 @@ onMounted(bootstrap)
|
||||
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、标签、子任务和 Markdown 备注会出现在这里。</p></div>
|
||||
</aside>
|
||||
|
||||
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='upcoming'}" @click="switchView('upcoming')"><CalendarDays/><span>计划</span></button><button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2/><span>回收站</span></button></nav>
|
||||
<button v-if="activeView!=='trash'" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarRange/><span>月历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user