feat: complete task management workflow

This commit is contained in:
2026-09-05 11:18:52 +08:00
parent f182c704a4
commit 57145c198b
17 changed files with 1514 additions and 115 deletions
+308 -22
View File
@@ -1,28 +1,314 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { CalendarDays, Check, CirclePlus, Inbox, ListTodo, Menu, Search, Settings, Sprout } from 'lucide-vue-next'
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,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
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'
const initialized = ref<boolean | null>(null)
const authenticated = ref(false)
const username = ref('')
const password = ref('')
const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([])
const tags = ref<Tag[]>([])
const tasks = ref<Task[]>([])
const trash = ref<Task[]>([])
const activeList = ref('')
const activeView = ref<View>('tasks')
const selectedTask = ref<Task | null>(null)
const selectedIds = ref(new Set<string>())
const title = ref('')
const query = ref('')
const error = ref('')
const notice = ref('')
const loading = ref(false)
const mobileSidebar = ref(false)
const mobileDetail = ref(false)
const markdownPreview = ref(false)
const showCompleted = ref(true)
const expandedFolders = ref(new Set<string>())
const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天'
if (activeView.value === 'upcoming') return '最近 7 天'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
})
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (query.value.trim()) return result
if (activeView.value === 'tasks') result = result.filter((task) => task.list_id === activeList.value)
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)
return result
})
const filteredTaskTree = computed(() => groupTaskTree(filterTasks(visibleTasks.value, query.value)))
const taskTree = computed(() => filteredTaskTree.value)
const selectedCount = computed(() => selectedIds.value.size)
const parentTasks = computed(() => tasks.value.filter((task) => !task.parent_id))
let searchTimer: number | undefined
watch(query, () => {
if (searchTimer) window.clearTimeout(searchTimer)
if (activeView.value === 'trash') return
searchTimer = window.setTimeout(() => loadAll(), 250)
})
async function api(path: string, options: RequestInit = {}) {
const response = await fetch('/api/v1' + path, {
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
...options,
})
if (!response.ok) {
let message = '请求失败'
try { const body = await response.json(); message = typeof body.detail === 'string' ? body.detail : message } catch { /* noop */ }
throw new Error(message)
}
return response.status === 204 ? null : response.json()
}
function toast(message: string) {
notice.value = message
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
}
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
async function bootstrap() {
try {
const status = await api('/setup/status')
initialized.value = status.initialized
if (status.initialized) {
await api('/me')
authenticated.value = true
await loadAll()
}
} catch { authenticated.value = false; initialized.value ??= true }
}
async function submitAuth() {
error.value = ''
try {
const path = initialized.value ? '/auth/login' : '/setup/initialize'
await api(path, { method: 'POST', body: JSON.stringify({ username: username.value, password: password.value }) })
initialized.value = true; authenticated.value = true
await loadAll()
} catch (reason) { fail(reason) }
}
async function loadTaskPages(path: string) {
const rows: Task[] = []
let cursor: string | null = null
do {
const separator = path.includes('?') ? '&' : '?'
const data = await api(cursor ? `${path}${separator}cursor=${encodeURIComponent(cursor)}` : path)
rows.push(...(data.items ?? data))
cursor = data.next_cursor ?? null
} while (cursor)
return rows
}
async function loadAll() {
loading.value = true; error.value = ''
try {
const taskPath = query.value ? `/tasks?q=${encodeURIComponent(query.value)}` : '/tasks'
const [folderData, listData, tagData, taskData] = await Promise.all([
api('/folders'), api('/lists'), api('/tags').catch(() => []), loadTaskPages(taskPath),
])
folders.value = folderData; lists.value = listData; tags.value = tagData; tasks.value = taskData
activeList.value ||= lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
} catch (reason) { fail(reason) } finally { loading.value = false }
}
async function loadTrash() {
try { trash.value = await loadTaskPages('/trash') } catch (reason) { fail(reason) }
}
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
if (view === 'trash') await loadTrash()
}
async function addTask() {
if (!title.value.trim() || !activeList.value) return
try {
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), list_id: activeList.value }) })
tasks.value.push(task); title.value = ''; selectTask(task); toast('任务已添加')
} catch (reason) { fail(reason) }
}
async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
const index = tasks.value.findIndex((item) => item.id === task.id)
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
return updated as Task
}
async function toggle(task: Task) {
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
}
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>)
toast('已保存')
} catch (reason) { fail(reason) }
}
async function removeTask(task: Task) {
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
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; toast('已移到回收站') } catch (reason) { fail(reason) }
}
async function restoreTask(task: Task) {
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) }
}
async function purgeTask(task: Task) {
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
try { await api(`/trash/${task.id}`, { method: 'DELETE' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('已永久删除') } catch (reason) { fail(reason) }
}
async function addSubtask() {
if (!selectedTask.value) return
const subtaskTitle = window.prompt('子任务名称')?.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 }) }); tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
}
function selectTask(task: Task) {
selectedTask.value = { ...task, tags: task.tags ? [...task.tags] : [] }
markdownPreview.value = false; mobileDetail.value = true
}
function toggleSelect(id: string) {
const next = new Set(selectedIds.value); next.has(id) ? next.delete(id) : next.add(id); selectedIds.value = next
}
async function batch(action: 'complete' | 'move' | 'delete') {
if (!selectedIds.value.size) return
const payload: Record<string, unknown> = { task_ids: [...selectedIds.value] }
if (action === 'complete') payload.completed = true
if (action === 'move') {
const listId = window.prompt('输入目标清单 ID', activeList.value)?.trim(); if (!listId) return; payload.list_id = listId
}
try {
if (action === 'delete') payload.soft_delete = true
await api('/tasks/batch', { method: 'POST', body: JSON.stringify(payload) })
selectedIds.value = new Set(); await loadAll(); toast(action === 'delete' ? '已批量移到回收站' : '批量操作完成')
} catch (reason) { fail(reason) }
}
async function createFolder() {
const name = window.prompt('文件夹名称')?.trim(); if (!name) return
try { folders.value.push(await api('/folders', { method: 'POST', body: JSON.stringify({ name }) })); toast('文件夹已创建') } catch (reason) { fail(reason) }
}
async function createList(folderId: string | null = null) {
const name = window.prompt('清单名称')?.trim(); if (!name) return
try { const item = await api('/lists', { method: 'POST', body: JSON.stringify({ name, folder_id: folderId }) }); lists.value.push(item); await switchView('tasks', item.id); toast('清单已创建') } catch (reason) { fail(reason) }
}
async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
const name = window.prompt('新名称', item.name)?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
}
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (!window.confirm(`删除“${item.name}”?`)) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadAll(); toast('已删除') } catch (reason) { fail(reason) }
}
async function createTag() {
const name = window.prompt('标签名称')?.trim(); if (!name) return
const color = window.prompt('标签颜色', '#F15A29') || '#F15A29'
try { tags.value.push(await api('/tags', { method: 'POST', body: JSON.stringify({ name, color }) })); toast('标签已创建') } catch (reason) { fail(reason) }
}
function taskHasTag(tag: Tag) { return !!selectedTask.value?.tags?.some((item) => item.id === tag.id) }
function toggleTag(tag: Tag) {
if (!selectedTask.value) return
selectedTask.value.tags = taskHasTag(tag) ? (selectedTask.value.tags ?? []).filter((item) => item.id !== tag.id) : [...(selectedTask.value.tags ?? []), tag]
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function formatDue(value: string | null) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>('.quick-input')?.focus()) }
type TaskList = { id:string; name:string; is_inbox:boolean }
type Task = { id:string; list_id:string; title:string; description:string; priority:number; completed:boolean; version:number; due_at:string|null }
const initialized=ref<boolean|null>(null), authenticated=ref(false), username=ref(''), password=ref(''), lists=ref<TaskList[]>([]), tasks=ref<Task[]>([]), activeList=ref(''), title=ref(''), error=ref('')
const activeName=computed(()=>lists.value.find(x=>x.id===activeList.value)?.name||'今天')
async function api(path:string, options:RequestInit={}) { const r=await fetch('/api/v1'+path,{credentials:'include',headers:{'Content-Type':'application/json',...(options.headers||{})},...options}); if(!r.ok) throw new Error((await r.json()).detail||'请求失败'); return r.status===204?null:r.json() }
async function bootstrap(){try{const s=await api('/setup/status');initialized.value=s.initialized;if(s.initialized){await api('/me');authenticated.value=true;await load()}}catch{authenticated.value=false}}
async function submitAuth(){error.value='';try{if(!initialized.value){await api('/setup/initialize',{method:'POST',body:JSON.stringify({username:username.value,password:password.value})});initialized.value=true}else await api('/auth/login',{method:'POST',body:JSON.stringify({username:username.value,password:password.value})});authenticated.value=true;await load()}catch(e){error.value=(e as Error).message}}
async function load(){lists.value=await api('/lists');activeList.value ||= lists.value[0]?.id;tasks.value=(await api('/tasks')).items}
async function addTask(){if(!title.value.trim()||!activeList.value)return;const t=await api('/tasks',{method:'POST',body:JSON.stringify({title:title.value.trim(),list_id:activeList.value})});tasks.value.push(t);title.value=''}
async function toggle(t:Task){const updated=await api(`/tasks/${t.id}`,{method:'PATCH',body:JSON.stringify({completed:!t.completed,version:t.version})});Object.assign(t,updated)}
function focusQuick(){document.querySelector<HTMLInputElement>('.quick input')?.focus()}
onMounted(bootstrap)
</script>
<template>
<div v-if="initialized===null" class="center">正在打开 dodo</div>
<div v-else-if="!authenticated" class="auth-shell"><section class="auth-card"><div class="brand">do<span>do</span></div><p>{{ initialized?'欢迎回来':'创建你的 dodo' }}</p><input v-model="username" placeholder="用户名"><input v-model="password" type="password" placeholder="密码(至少12位)" @keyup.enter="submitAuth"><button @click="submitAuth">{{ initialized?'登录':'开始使用' }}</button><small v-if="error">{{error}}</small></section></div>
<div v-else class="shell">
<aside><div class="brand small">do<span>do</span></div><nav><button class="active"><Inbox/>收集箱</button><button><ListTodo/>今天</button><button><CalendarDays/>最近7天</button><button><Sprout/>习惯</button></nav><div class="lists"><label>我的清单</label><button v-for="l in lists" :key="l.id" @click="activeList=l.id"><i></i>{{l.name}}</button></div><button class="settings"><Settings/>设置</button></aside>
<main><header><button class="mobile-menu"><Menu/></button><div><p>今天也慢慢来</p><h1>{{activeName}}</h1></div><button class="search"><Search/>搜索</button></header><form @submit.prevent="addTask" class="quick"><CirclePlus/><input v-model="title" placeholder="添加任务"><button>添加</button></form><section class="task-list"><article v-for="t in tasks.filter(x=>x.list_id===activeList)" :key="t.id" :class="{done:t.completed}"><button class="check" @click="toggle(t)"><Check v-if="t.completed"/></button><div><strong>{{t.title}}</strong><p v-if="t.description">{{t.description}}</p></div><span v-if="t.priority" class="priority">P{{4-t.priority}}</span></article><div v-if="!tasks.filter(x=>x.list_id===activeList).length" class="empty"><ListTodo/><b>这里还很安静</b><span>写下第一件想完成的小事吧</span></div></section></main>
<aside class="detail"><span>任务详情</span><div class="paper"><b>选中一个任务</b><p>日期提醒标签和备注会出现在这里</p></div></aside>
<nav class="bottom"><button><ListTodo/><span>今天</span></button><button><Inbox/><span>任务</span></button><button><CalendarDays/><span>日历</span></button><button><Sprout/><span>习惯</span></button></nav>
<button class="fab" @click="focusQuick"><CirclePlus/></button>
</div>
<div v-if="initialized === null" class="center"><span class="loader" />正在打开 dodo</div>
<div v-else-if="!authenticated" class="auth-shell">
<section class="auth-card">
<div class="brand">do<span>do</span></div><p>{{ initialized ? '欢迎回来,继续把生活理顺。' : '创建你的 dodo' }}</p>
<label>用户名<input v-model="username" autocomplete="username" placeholder="你的用户名"></label>
<label>密码<input v-model="password" type="password" autocomplete="current-password" placeholder="至少 12 位" @keyup.enter="submitAuth"></label>
<button class="primary" @click="submitAuth">{{ initialized ? '登录' : '开始使用' }}</button>
<small v-if="error" role="alert">{{ error }}</small>
</section>
</div>
<div v-else class="shell">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :class="{ open: mobileSidebar }">
<div class="brand-row"><div class="brand small">do<span>do</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
<nav class="primary-nav">
<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==='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>
<div class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block">
<div class="folder-row"><button @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/>{{folder.name}}</button><span class="row-actions"><button aria-label="重命名文件夹" @click="renameEntity('folders',folder)"><Pencil/></button><button aria-label="删除文件夹" @click="deleteEntity('folders',folder)"><Trash2/></button><button aria-label="在文件夹中新建清单" @click="createList(folder.id)"><Plus/></button></span></div>
<button v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :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 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>
</aside>
<main>
<header class="topbar">
<button class="icon mobile-only" aria-label="打开菜单" @click="mobileSidebar=true"><Menu /></button>
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header>
<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>
<section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id">
<article class="task-row" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}">
<button v-if="activeView!=='trash'" class="select-box" :class="{checked:selectedIds.has(node.task.id)}" aria-label="选择任务" @click.stop="toggleSelect(node.task.id)"><Check v-if="selectedIds.has(node.task.id)"/></button>
<button v-if="activeView!=='trash'" class="check" :class="`p${node.task.priority}`" aria-label="切换完成状态" @click.stop="toggle(node.task)"><Check v-if="node.task.completed"/></button>
<button class="task-main" @click="activeView==='trash'?undefined:selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></button>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask" :class="{done:subtask.completed}"><GripVertical/><button class="check" @click="toggle(subtask)"><Check v-if="subtask.completed"/></button><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>
</main>
<aside class="detail" :class="{open:mobileDetail}">
<div class="detail-head"><span>任务详情</span><button class="icon mobile-only" aria-label="关闭详情" @click="mobileDetail=false"><X/></button></div>
<div v-if="selectedTask" class="detail-form">
<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.number="selectedTask.priority" @change="saveTask"><option :value="0"></option><option :value="1"></option><option :value="2"></option><option :value="3"></option></select></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>
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
</div>
<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>
<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>
</template>