feat: swipe complete, list archive, today habits and calendar subscriptions
ci / docker (push) Successful in 3m20s
ci / docker (push) Successful in 3m20s
This commit is contained in:
+100
-15
@@ -6,15 +6,16 @@ import {
|
||||
Settings, Trash2, X, Repeat2,
|
||||
} from 'lucide-vue-next'
|
||||
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||
import { isTaskView, shouldToggleHabitSwipe } from './lib/mvp-utils'
|
||||
import { isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
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 CalendarSubscription = { id: string; name: string; url: string; color?: string }
|
||||
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; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings'
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'calendar' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
const authenticated = ref(false)
|
||||
@@ -22,6 +23,10 @@ const username = ref('')
|
||||
const password = ref('')
|
||||
const folders = ref<FolderItem[]>([])
|
||||
const lists = ref<TaskList[]>([])
|
||||
const archivedLists = ref<TaskList[]>([])
|
||||
const calendarSubscriptions = ref<CalendarSubscription[]>([])
|
||||
const calendarName = ref('合并日历')
|
||||
const calendarUrl = ref('https://cal.bboy.app/all.ics')
|
||||
const tags = ref<Tag[]>([])
|
||||
const tasks = ref<Task[]>([])
|
||||
const trash = ref<Task[]>([])
|
||||
@@ -44,10 +49,13 @@ const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageS
|
||||
const expandedFolders = ref(new Set<string>())
|
||||
const navigationLoaded = ref(false)
|
||||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskSwipeOffsets = ref<Record<string, number>>({})
|
||||
let suppressTaskClickId = ''
|
||||
|
||||
const activeName = computed(() => {
|
||||
if (activeView.value === 'trash') return '回收站'
|
||||
if (activeView.value === 'today') return '今天'
|
||||
if (activeView.value === 'calendar') return '日历'
|
||||
if (activeView.value === 'upcoming') return '最近 7 天'
|
||||
if (activeView.value === 'habits') return '习惯'
|
||||
if (activeView.value === 'settings') return '设置与数据'
|
||||
@@ -174,6 +182,7 @@ async function loadNavigation(force = false) {
|
||||
activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
|
||||
}
|
||||
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||||
await loadArchivedLists()
|
||||
}
|
||||
async function loadAll() {
|
||||
loading.value = true; error.value = ''
|
||||
@@ -202,6 +211,7 @@ async function switchView(view: View, listId?: string) {
|
||||
page.value = 1
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
else if (view === 'calendar') await loadCalendarTab()
|
||||
else if (!isTaskView(view)) tasks.value = []
|
||||
else await loadAll()
|
||||
}
|
||||
@@ -222,20 +232,50 @@ async function patchTask(task: Task, patch: Partial<Task>) {
|
||||
async function toggle(task: Task) {
|
||||
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
|
||||
}
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
||||
}
|
||||
function startTaskSwipe(task: Task, event: TouchEvent) {
|
||||
if (activeView.value === 'trash' || loading.value) return
|
||||
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
|
||||
const touch = event.touches[0]
|
||||
if (touch) taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
|
||||
if (touch) {
|
||||
taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
|
||||
taskSwipeOffsets.value[task.id] = 0
|
||||
}
|
||||
}
|
||||
function moveTaskSwipe(task: Task, event: TouchEvent) {
|
||||
const start = taskSwipeStart.value
|
||||
const touch = event.touches[0]
|
||||
if (!start || start.id !== task.id || !touch) return
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) taskSwipeOffsets.value[task.id] = Math.min(deltaX, 92)
|
||||
}
|
||||
async function finishTaskSwipe(task: Task, event: TouchEvent) {
|
||||
const start = taskSwipeStart.value
|
||||
taskSwipeStart.value = null
|
||||
const offset = taskSwipeOffsets.value[task.id] ?? 0
|
||||
taskSwipeOffsets.value[task.id] = 0
|
||||
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
|
||||
const touch = event.changedTouches[0]
|
||||
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) await toggle(task)
|
||||
if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
|
||||
suppressTaskClickId = task.id
|
||||
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
|
||||
await toggle(task)
|
||||
} else if (offset) {
|
||||
taskSwipeOffsets.value[task.id] = 0
|
||||
}
|
||||
}
|
||||
function cancelTaskSwipe() {
|
||||
function cancelTaskSwipe(task?: Task) {
|
||||
taskSwipeStart.value = null
|
||||
if (task) taskSwipeOffsets.value[task.id] = 0
|
||||
}
|
||||
function selectTaskUnlessSwiped(task: Task) {
|
||||
if (suppressTaskClickId === task.id) {
|
||||
suppressTaskClickId = ''
|
||||
return
|
||||
}
|
||||
selectTask(task)
|
||||
}
|
||||
async function saveTask() {
|
||||
if (!selectedTask.value?.title.trim()) return
|
||||
@@ -279,9 +319,34 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
|
||||
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 (kind === 'lists') {
|
||||
if (!window.confirm(`归档“${item.name}”?任务会移到收集箱,历史数据保留。`)) return
|
||||
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadArchivedLists(); await refreshAll(); toast('清单已归档') } catch (reason) { fail(reason) }
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`删除“${item.name}”?`)) return
|
||||
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
|
||||
}
|
||||
async function loadArchivedLists() {
|
||||
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
|
||||
}
|
||||
async function restoreList(item: TaskList) {
|
||||
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
|
||||
}
|
||||
async function loadCalendarTab() {
|
||||
try { calendarSubscriptions.value = await api('/calendar-subscriptions') } catch { calendarSubscriptions.value = [] }
|
||||
}
|
||||
async function addCalendarSubscription() {
|
||||
if (!calendarUrl.value.trim()) return
|
||||
try {
|
||||
await api('/calendar-subscriptions', { method: 'POST', body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }) })
|
||||
await loadCalendarTab(); toast('日历已订阅')
|
||||
} catch (reason) { fail(reason) }
|
||||
}
|
||||
async function removeCalendarSubscription(id: string) {
|
||||
if (!window.confirm('取消这个日历订阅?')) return
|
||||
try { await api(`/calendar-subscriptions/${id}`, { method: 'DELETE' }); await loadCalendarTab(); toast('已取消订阅') } catch (reason) { fail(reason) }
|
||||
}
|
||||
async function createTag() {
|
||||
const name = window.prompt('标签名称')?.trim(); if (!name) return
|
||||
const color = window.prompt('标签颜色', '#F15A29') || '#F15A29'
|
||||
@@ -328,6 +393,7 @@ 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')"><CalendarDays />日历</button>
|
||||
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
|
||||
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
</nav>
|
||||
@@ -335,9 +401,13 @@ onMounted(bootstrap)
|
||||
<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>
|
||||
<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)"><ArchiveRestore/></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>
|
||||
<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)"><ArchiveRestore/></button></span></button>
|
||||
<template v-if="archivedLists.length">
|
||||
<div class="section-title"><span>已归档清单</span></div>
|
||||
<button v-for="list in archivedLists" :key="list.id" class="list-row archived-row" @click="restoreList(list)"><ArchiveRestore/><span>{{list.name}}</span><span class="row-actions"><button aria-label="恢复清单" @click.stop="restoreList(list)"><ArchiveRestore/></button></span></button>
|
||||
</template>
|
||||
</div>
|
||||
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</aside>
|
||||
@@ -348,27 +418,42 @@ 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="['habits','settings'].includes(activeView)">
|
||||
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
||||
<template v-if="['habits','settings','today','calendar'].includes(activeView)">
|
||||
<MvpPanel v-if="['habits','settings'].includes(activeView)" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
||||
<template v-else-if="activeView === 'calendar'">
|
||||
<header class="view-intro"><div><small>订阅外部日历,每天自动同步</small><h2>日历</h2></div><button class="soft-button" @click="loadCalendarTab"><RefreshCw />刷新</button></header>
|
||||
<div class="settings-grid">
|
||||
<article class="tool-card wide"><Upload /><h3>添加订阅</h3><p>粘贴 ICS / webcal 链接,例如 cal.bboy.app 的合并日历。</p><div class="sub-add"><input v-model="calendarName" placeholder="名称(可选)" aria-label="订阅名称"><input v-model="calendarUrl" placeholder="https://…/all.ics" aria-label="订阅地址"><button class="primary-small" @click="addCalendarSubscription">订阅</button></div><div v-if="calendarSubscriptions.length"><div v-for="sub in calendarSubscriptions" :key="sub.id" class="session-row"><span><b>{{sub.name}}</b><small>{{sub.url}}</small></span><button class="danger-text" @click="removeCalendarSubscription(sub.id)">取消订阅</button></div></div><p v-else class="hint">还没有日历订阅</p></article>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="activeView==='today'">
|
||||
<div class="list-toolbar"><label><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>共 {{visibleTasks.length}} 项</span></div>
|
||||
<h3 class="section-heading"><ListTodo/>今日任务</h3>
|
||||
</template>
|
||||
<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>第 {{page}} / {{totalPages}} 页 · 共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
||||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||||
<section class="task-list" :class="{loading}">
|
||||
<template v-for="node in taskTree" :key="node.task.id">
|
||||
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe">
|
||||
<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="activeView!=='trash'" class="task-swipe-hint">{{ node.task.completed ? '已完成' : '右滑' }}</span>
|
||||
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||||
<span class="swipe-bg">✓ 完成</span>
|
||||
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(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></div>
|
||||
<button v-if="activeView!=='trash'" class="task-swipe-hint" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">{{ node.task.completed ? '已完成' : '完成' }}</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 swipeable" :class="{done:subtask.completed}" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe"><GripVertical/><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button><span class="task-swipe-hint">{{ subtask.completed ? '已完成' : '右滑' }}</span></article>
|
||||
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg">✓ 完成</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
<div v-if="activeView==='today'">
|
||||
<h3 class="section-heading"><Repeat2/>今日习惯</h3>
|
||||
<MvpPanel view="today-habits" @changed="refreshAll" @notice="toast" />
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
@@ -389,7 +474,7 @@ 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==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<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')"><CalendarDays/><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>
|
||||
|
||||
Reference in New Issue
Block a user