feat: complete task management workflow
This commit is contained in:
+308
-22
@@ -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>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { filterTasks, groupTaskTree, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
tags?: { name: string }[]
|
||||
list_name?: string
|
||||
}
|
||||
|
||||
const tasks: SearchTask[] = [
|
||||
{ id: '1', title: 'Write release notes', description: 'mention **API**', parent_id: null, completed: false },
|
||||
{ id: '2', title: 'Check links', description: '', parent_id: '1', completed: false },
|
||||
{ id: '3', title: 'Buy milk', description: '', parent_id: null, completed: true },
|
||||
]
|
||||
|
||||
describe('task utilities', () => {
|
||||
it('filters task titles and markdown descriptions case-insensitively', () => {
|
||||
expect(filterTasks(tasks, 'api').map((task) => task.id)).toEqual(['1'])
|
||||
expect(filterTasks(tasks, 'WRITE').map((task) => task.id)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('keeps a one-level subtask tree without duplicating children', () => {
|
||||
expect(groupTaskTree(tasks)).toEqual([{ task: tasks[0], subtasks: [tasks[1]] }, { task: tasks[2], subtasks: [] }])
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
expect(html).toContain('<strong>bold</strong>')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
expect(html).not.toContain('<script>')
|
||||
})
|
||||
|
||||
it('filters titles, descriptions, tags, and list names', () => {
|
||||
const searchable: SearchTask[] = [
|
||||
...tasks,
|
||||
{ id: '4', title: 'Plan', tags: [{ name: '重要' }], list_name: '工作清单' },
|
||||
]
|
||||
expect(filterTasks(searchable, '重要').map((task) => task.id)).toEqual(['4'])
|
||||
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
|
||||
})
|
||||
|
||||
it('formats API dates in local wall-clock time', () => {
|
||||
const original = process.env.TZ
|
||||
process.env.TZ = 'Asia/Shanghai'
|
||||
expect(toDateTimeLocal('2026-09-05T12:30:00Z')).toBe('2026-09-05T20:30')
|
||||
process.env.TZ = original
|
||||
expect(toDateTimeLocal(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
export type MinimalTask = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
parent_id?: string | null
|
||||
completed?: boolean
|
||||
tags?: { name: string }[]
|
||||
list_name?: string
|
||||
}
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const renderInline = (value: string) => {
|
||||
let out = escapeHtml(value)
|
||||
out = out.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
out = out.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
)
|
||||
return out
|
||||
}
|
||||
|
||||
export function renderMarkdown(markdown = '') {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
const html: string[] = []
|
||||
let inList = false
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push('</ul>')
|
||||
inList = false
|
||||
}
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trimEnd()
|
||||
if (!line.trim()) {
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
if (line.startsWith('# ')) {
|
||||
closeList()
|
||||
html.push(`<h1>${renderInline(line.slice(2))}</h1>`)
|
||||
} else if (line.startsWith('## ')) {
|
||||
closeList()
|
||||
html.push(`<h2>${renderInline(line.slice(3))}</h2>`)
|
||||
} else if (/^[-*] /.test(line)) {
|
||||
if (!inList) {
|
||||
html.push('<ul>')
|
||||
inList = true
|
||||
}
|
||||
html.push(`<li>${renderInline(line.slice(2))}</li>`)
|
||||
} else {
|
||||
closeList()
|
||||
html.push(`<p>${renderInline(line)}</p>`)
|
||||
}
|
||||
}
|
||||
closeList()
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return tasks
|
||||
return tasks.filter((task) => {
|
||||
const haystack = [
|
||||
task.title,
|
||||
task.description ?? '',
|
||||
task.list_name ?? '',
|
||||
...(task.tags ?? []).map((tag) => tag.name),
|
||||
].join(' ')
|
||||
return haystack.toLowerCase().includes(q)
|
||||
})
|
||||
}
|
||||
|
||||
export function groupTaskTree<T extends MinimalTask>(tasks: T[]) {
|
||||
const children = new Map<string, T[]>()
|
||||
for (const task of tasks) {
|
||||
if (!task.parent_id) continue
|
||||
children.set(task.parent_id, [...(children.get(task.parent_id) ?? []), task])
|
||||
}
|
||||
return tasks.filter((task) => !task.parent_id).map((task) => ({ task, subtasks: children.get(task.id) ?? [] }))
|
||||
}
|
||||
|
||||
export function toDateTimeLocal(value: string | null | undefined) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.valueOf())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hour}:${minute}`
|
||||
}
|
||||
|
||||
export function fromDateTimeLocal(value: string) {
|
||||
if (!value) return null
|
||||
return new Date(value).toISOString()
|
||||
}
|
||||
+11
-2
@@ -1,3 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#29251f;background:#f8f3e8;--accent:#f15a29;--line:#e8dfcf}*{box-sizing:border-box}body{margin:0}button,input{font:inherit}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.auth-card{width:min(390px,90vw);background:#fffdf7;padding:36px;border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 40px #5d4b3320;display:grid;gap:14px}.brand{font-size:38px;font-weight:800;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:28px;padding:22px}.auth-card input,.quick{border:1px solid var(--line);border-radius:10px;background:white}.auth-card input{padding:12px}.auth-card button,.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:11px 16px;font-weight:650}.auth-card small{color:#c0341d}.shell{height:100vh;display:grid;grid-template-columns:230px minmax(400px,1fr) 310px;background:#fffdf8}.shell>aside{border-right:1px solid var(--line);background:#f8f3e8;display:flex;flex-direction:column}.shell nav,.lists{display:grid;padding:8px 12px;gap:3px}.shell nav button,.lists button,.settings{display:flex;gap:10px;align-items:center;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#625c52;text-align:left}.shell nav svg,.settings svg{width:18px}.shell nav .active{background:#f3dfd2;color:#b73d18;font-weight:650}.lists label{font-size:12px;color:#9a9184;padding:15px 12px 5px}.lists i{width:9px;height:9px;background:#df9c61;border-radius:3px}.settings{margin:auto 12px 15px}main{min-width:0;padding:34px 38px;overflow:auto}header{display:flex;align-items:center;justify-content:space-between}header p{margin:0;color:#9a9184;font-size:13px}h1{font-size:28px;margin:4px 0 25px}.search{display:flex;gap:8px;border:1px solid var(--line);background:#faf7f0;color:#777065;padding:8px 12px;border-radius:10px}.search svg{width:16px}.quick{display:flex;align-items:center;padding:6px 7px 6px 13px;box-shadow:0 4px 18px #5d4b330d}.quick svg{color:var(--accent);width:20px}.quick input{flex:1;border:0;outline:0;padding:10px;background:transparent}.task-list{margin-top:22px;border-top:1px solid var(--line)}article{display:flex;align-items:center;gap:12px;min-height:54px;border-bottom:1px solid var(--line);transition:.2s}article.done{opacity:.45}article.done strong{text-decoration:line-through}.check{width:20px;height:20px;border:1.5px solid #b8ad9e;border-radius:50%;background:white;padding:2px;color:white}.done .check{background:var(--accent);border-color:var(--accent)}.check svg{width:13px;height:13px}.priority{margin-left:auto;color:var(--accent);font-size:12px}.empty{padding:90px 20px;display:grid;place-items:center;color:#a59b8d;gap:9px}.empty svg{width:42px;height:42px;stroke-width:1}.empty b{color:#6e675d}.detail{border-left:1px solid var(--line)!important;border-right:0!important;padding:28px 22px}.detail>span{color:#8f867a;font-size:13px}.paper{margin-top:22px;background:#fffaf0;border:1px dashed #dfd2be;border-radius:12px;padding:22px}.paper p{color:#948a7c;font-size:14px;line-height:1.6}.bottom,.fab,.mobile-menu{display:none}
|
||||
@media(max-width:800px){.shell{display:block;height:auto;min-height:100vh}.shell>aside,.detail{display:none}main{padding:24px 18px 100px}.search{font-size:0}.bottom{display:flex;position:fixed;bottom:0;left:0;right:0;background:#fffdf8;border-top:1px solid var(--line);justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))}.bottom button{border:0;background:transparent;color:#766e62;display:grid;place-items:center;font-size:11px;gap:2px}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;right:20px;bottom:78px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:white;box-shadow:0 8px 20px #f15a2950}.mobile-menu{display:block;border:0;background:transparent;padding:0;margin-right:12px}header>div{margin-right:auto}.quick button{display:none}}
|
||||
|
||||
:root{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif;color:#2d2924;background:#f8f3e8;--accent:#f15a29;--accent-soft:#fbe6dc;--paper:#fffdf8;--sidebar:#f6f0e4;--line:#e7ddcc;--muted:#8b8275;--danger:#bd3827;--shadow:0 12px 36px rgba(78,58,34,.10)}
|
||||
*{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)}
|
||||
.shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none}
|
||||
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.batch-bar{position:sticky;top:-12px;z-index:5;display:flex;align-items:center;gap:7px;padding:8px 10px;margin-bottom:8px;border:1px solid #f3c2ae;border-radius:10px;background:#fff7f2;box-shadow:0 8px 18px rgba(96,62,40,.08)}.batch-bar b{font-size:13px;margin-right:auto}.batch-bar>button:not(.icon){display:flex;gap:4px;align-items:center;border:0;background:#fff;border-radius:7px;padding:6px 9px}.batch-bar svg{width:14px}.batch-bar .danger{color:var(--danger)}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .15s}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(2px)}.select-box,.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.select-box{width:16px;height:16px;flex-basis:16px;border-radius:3px;opacity:.2}.task-row:hover .select-box,.select-box.checked{opacity:1}.select-box.checked{background:#796f63;color:#fff}.check svg,.select-box svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.select-box{opacity:.45}.row-actions{opacity:1}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.batch-bar{overflow:auto}.batch-bar b{white-space:nowrap}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
|
||||
|
||||
Reference in New Issue
Block a user