Revert "fix: declutter today view and task rows"
ci / docker (push) Successful in 3m25s

This reverts commit 081cddaf54.
This commit is contained in:
2026-09-07 15:26:11 +08:00
parent 081cddaf54
commit 76d9c1576f
5 changed files with 48 additions and 96 deletions
+34 -34
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { import {
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder, ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, MoreHorizontal, Pencil, Plus, Search, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2, Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
@@ -37,6 +37,7 @@ const loading = ref(false)
const mobileSidebar = ref(false) const mobileSidebar = ref(false)
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
const mobileDetail = ref(false) const mobileDetail = ref(false)
const mobileMore = ref(false)
const moreSettingsOpen = ref(false) const moreSettingsOpen = ref(false)
const markdownPreview = ref(false) const markdownPreview = ref(false)
const showCompleted = ref(true) const showCompleted = ref(true)
@@ -50,8 +51,6 @@ const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null) const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
const taskSwipeOffsets = ref<Record<string, number>>({}) const taskSwipeOffsets = ref<Record<string, number>>({})
const taskComposeOpen = ref(false) const taskComposeOpen = ref(false)
const sidebarActionMenu = ref('')
const archivedListsOpen = ref(false)
const composeTitle = ref('') const composeTitle = ref('')
const composeListId = ref('') const composeListId = ref('')
const composeDueAt = ref('') const composeDueAt = ref('')
@@ -296,7 +295,7 @@ async function switchView(view: View, listId?: string) {
activeView.value = view activeView.value = view
if (listId) activeList.value = listId if (listId) activeList.value = listId
page.value = 1 page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false
if (view === 'trash') await loadTrash() if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView() else if (view === 'today') await loadTodayView()
else if (!isTaskView(view)) tasks.value = [] else if (!isTaskView(view)) tasks.value = []
@@ -460,8 +459,6 @@ async function restoreList(item: TaskList) {
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) } try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
} }
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next } function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function toggleSidebarActionMenu(id: string) { sidebarActionMenu.value = sidebarActionMenu.value === id ? '' : id }
function closeSidebarActionMenu() { sidebarActionMenu.value = '' }
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 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 previousPage() { function previousPage() {
if (page.value <= 1 || loading.value) return if (page.value <= 1 || loading.value) return
@@ -490,7 +487,7 @@ onMounted(bootstrap)
</div> </div>
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed }"> <div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" /> <div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :class="{ open: mobileSidebar }" @click="closeSidebarActionMenu"> <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> <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"> <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==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
@@ -503,13 +500,13 @@ onMounted(bootstrap)
<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="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 class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block"> <div v-for="folder in folders" :key="folder.id" class="folder-block">
<div class="folder-row"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></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><button class="mini-icon sidebar-more mobile-only" aria-label="文件夹操作" @click.stop="toggleSidebarActionMenu(`folder:${folder.id}`)"><MoreHorizontal/></button><div v-if="sidebarActionMenu===`folder:${folder.id}`" class="sidebar-row-menu" @click="closeSidebarActionMenu"><button @click="renameEntity('folders',folder)"><Pencil/>重命名</button><button @click="createList(folder.id)"><Plus/>新建清单</button><button @click="deleteEntity('folders',folder)"><Trash2/>删除</button></div></div> <div class="folder-row"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></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>
<div 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}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="重命名清单" @click="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click="deleteEntity('lists',list)"><ArchiveRestore/></button></span><button class="mini-icon sidebar-more mobile-only" aria-label="清单操作" @click.stop="toggleSidebarActionMenu(`list:${list.id}`)"><MoreHorizontal/></button><div v-if="sidebarActionMenu===`list:${list.id}`" class="sidebar-row-menu" @click="closeSidebarActionMenu"><button @click="renameEntity('lists',list)"><Pencil/>重命名</button><button @click="deleteEntity('lists',list)"><ArchiveRestore/>归档</button></div></div> <div 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}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="重命名清单" @click="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click="deleteEntity('lists',list)"><ArchiveRestore/></button></span></div>
</div> </div>
<div 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}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="重命名清单" @click="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click="deleteEntity('lists',list)"><ArchiveRestore/></button></span><button class="mini-icon sidebar-more mobile-only" aria-label="清单操作" @click.stop="toggleSidebarActionMenu(`list:${list.id}`)"><MoreHorizontal/></button><div v-if="sidebarActionMenu===`list:${list.id}`" class="sidebar-row-menu" @click="closeSidebarActionMenu"><button @click="renameEntity('lists',list)"><Pencil/>重命名</button><button @click="deleteEntity('lists',list)"><ArchiveRestore/>归档</button></div></div> <div 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}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="重命名清单" @click="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click="deleteEntity('lists',list)"><ArchiveRestore/></button></span></div>
<template v-if="archivedLists.length"> <template v-if="archivedLists.length">
<button class="section-title archived-heading" :aria-expanded="archivedListsOpen" @click="archivedListsOpen=!archivedListsOpen"><span>已归档清单</span><ChevronDown v-if="archivedListsOpen"/><ChevronRight v-else/></button> <div class="section-title"><span>已归档清单</span></div>
<template v-if="archivedListsOpen"><div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div></template> <div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div>
</template> </template>
</div> </div>
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button> <button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
@@ -525,29 +522,31 @@ onMounted(bootstrap)
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" /> <MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
</template> </template>
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <template v-else>
<section :class="{ 'today-block': activeView==='today' }"> <template v-if="activeView==='today'">
<h3 v-if="activeView==='today'" class="section-heading"><ListTodo/>今日任务</h3> <h3 class="section-heading"><ListTodo/>今日任务</h3>
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · 共 ${totalTasks} 项` : `共 ${totalTasks} 项` }}</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div> </template>
<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> <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>
<section class="task-list" :class="{loading}"> <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>
<template v-for="node in taskTree" :key="node.task.id"> <section class="task-list" :class="{loading}">
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)"> <template v-for="node in taskTree" :key="node.task.id">
<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></span></div> <article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="activeView!=='trash'" class="sr-only" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">切换任务状态</button> <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></span></div>
<span v-if="node.task.priority" class="priority-dot" :class="`p${node.task.priority}`" :title="`优先级:${['','低','中','高'][node.task.priority]}`"/> <button v-if="activeView!=='trash'" class="sr-only" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">切换任务状态</button>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button> <span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
<button v-else class="icon ghost task-delete" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button> <button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button> <button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
</article> <button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><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="sr-only" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">切换任务状态</button></article> </article>
</template> <article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><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="sr-only" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">切换任务状态</button></article>
<div v-if="!visibleTasks.length&&!loading" class="empty" :class="{ compact: activeView==='today' }"><ListTodo/><b>{{query?'没有匹配的任务':activeView==='today'?'今天没有任务':'这里还很安静'}}</b><span v-if="activeView!=='today'">{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div> </template>
</section> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
<section v-if="activeView==='today'" class="today-block"><h3 class="section-heading"><Repeat2/>今日习惯</h3><MvpPanel ref="habitComposer" view="today-habits" @changed="refreshAll" @notice="toast" /></section> <div v-if="activeView==='today'">
<section v-if="activeView==='today'" class="today-block"><h3 class="section-heading"><CalendarHeart/>倒数日提醒</h3><CountdownPanel view="today" @notice="toast" /></section> <h3 class="section-heading"><Repeat2/>今日习惯</h3>
</template> <MvpPanel ref="habitComposer" view="today-habits" @changed="refreshAll" @notice="toast" />
</div>
</template>
</main> </main>
<aside class="detail" :class="{open:mobileDetail}"> <aside class="detail" :class="{open:mobileDetail}">
@@ -566,6 +565,7 @@ onMounted(bootstrap)
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级子任务和 Markdown 备注会出现在这里</p></div> <div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级子任务和 Markdown 备注会出现在这里</p></div>
</aside> </aside>
<div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><button @click="switchView('settings')"><Settings/>设置与数据</button></section></div>
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav> <nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" /> <FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
<Transition name="task-compose"> <Transition name="task-compose">
+3 -9
View File
@@ -12,7 +12,6 @@ type Countdown = {
} }
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; icon:string; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean } type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; icon:string; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
const emit = defineEmits<{ notice: [message: string] }>() const emit = defineEmits<{ notice: [message: string] }>()
const props = defineProps<{ view?: 'full' | 'today' }>()
const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([]) const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
const showArchived = ref(false), open = ref(false), busy = ref(false) const showArchived = ref(false), open = ref(false), busy = ref(false)
const editingId = ref<string|null>(null), error = ref('') const editingId = ref<string|null>(null), error = ref('')
@@ -21,7 +20,6 @@ const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:
const form = ref<Form>(freshForm()) const form = ref<Form>(freshForm())
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` })) const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
const todayItems = computed(() => items.value.filter((item) => item.days >= 0 && item.days <= 7).slice(0, 3))
const titleInput = ref<HTMLInputElement | null>(null) const titleInput = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null let previousFocus: HTMLElement | null = null
@@ -107,12 +105,8 @@ onBeforeUnmount(() => { previousFocus = null })
<template> <template>
<section class="countdown-view" :class="{ loading:busy }"> <section class="countdown-view" :class="{ loading:busy }">
<div v-if="props.view==='today'" class="countdown-reminders"> <div class="countdown-content" :inert="open" :aria-hidden="open ? 'true' : undefined">
<article v-for="item in todayItems" :key="item.id"><span class="countdown-icon">{{item.icon}}</span><span><b>{{item.title}}</b><small>{{countdownDayText(item.days)}} · {{primaryDate(item)}}</small></span></article> <header class="countdown-hero"><div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div></header>
<div v-if="!todayItems.length&&!busy" class="empty-panel compact">未来 7 天没有重要日子</div>
</div>
<div v-else class="countdown-content" :inert="open" :aria-hidden="open ? 'true' : undefined">
<header class="countdown-hero page-subtitle"><small>记住值得期待与纪念的日子</small></header>
<p v-if="error" class="inline-error">{{error}}</p> <p v-if="error" class="inline-error">{{error}}</p>
<div class="countdown-grid"> <div class="countdown-grid">
<article v-for="item in items" :key="item.id" class="countdown-card" :class="{ pinned:item.pinned }"> <article v-for="item in items" :key="item.id" class="countdown-card" :class="{ pinned:item.pinned }">
@@ -126,7 +120,7 @@ onBeforeUnmount(() => { previousFocus = null })
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button> <button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span>{{item.icon}}</span><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div> <div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span>{{item.icon}}</span><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
</div> </div>
<Transition v-if="props.view!=='today'" name="countdown-compose"> <Transition name="countdown-compose">
<div v-if="open" class="countdown-modal-mask" @click.self="closeDialog"><form class="countdown-modal" :style="composerStyle" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus"> <div v-if="open" class="countdown-modal-mask" @click.self="closeDialog"><form class="countdown-modal" :style="composerStyle" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus">
<header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header> <header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
<label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label> <label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label>
+5 -5
View File
@@ -286,13 +286,13 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<section class="mvp-view" :class="{ loading: busy, 'today-habit-panel': view === 'today-habits' }"> <section class="mvp-view" :class="{ loading: busy }">
<p v-if="error" class="inline-error">{{ error }}</p> <p v-if="error" class="inline-error">{{ error }}</p>
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 --> <!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-if="view === 'habits' || view === 'today-habits'"> <template v-if="view === 'habits' || view === 'today-habits'">
<header v-if="view === 'habits'" class="view-intro page-subtitle"> <header v-if="view === 'habits'" class="view-intro">
<small>把想坚持的事变成每天的日常</small> <div><small>把想坚持的事变成每天的日常</small><h2>习惯</h2></div>
<button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button> <button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button>
</header> </header>
@@ -336,8 +336,8 @@ onBeforeUnmount(() => {
<!-- 设置与数据 --> <!-- 设置与数据 -->
<template v-else> <template v-else>
<header class="view-intro page-subtitle"> <header class="view-intro">
<small>备份迁移与安全</small> <div><small>备份迁移与安全</small><h2>设置与数据</h2></div>
</header> </header>
<div class="settings-grid"> <div class="settings-grid">
<article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>下载完整 JSON 备份或从备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 JSON</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article> <article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>下载完整 JSON 备份或从备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 JSON</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
File diff suppressed because one or more lines are too long
+1 -43
View File
@@ -91,7 +91,7 @@ describe('unified floating add interaction', () => {
expect(floatingAdd).toContain('@pointermove="moveDrag"') expect(floatingAdd).toContain('@pointermove="moveDrag"')
expect(floatingAdd).toContain("emit('activate',") expect(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging') expect(css).toContain('.unified-fab.dragging')
expect(countdownPanel).toMatch(/<Transition[^>]*name="countdown-compose"/) expect(countdownPanel).toContain('<Transition name="countdown-compose">')
expect(css).toContain('.countdown-compose-enter-active') expect(css).toContain('.countdown-compose-enter-active')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}') expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
}) })
@@ -124,48 +124,6 @@ describe('unified floating add interaction', () => {
}) })
}) })
describe('first-pass information hierarchy cleanup', () => {
it('removes repeated page titles while keeping lightweight subtitles', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(countdownPanel).not.toContain('<h2>倒数日</h2>')
expect(mvpPanel).toContain('把想坚持的事,变成每天的日常')
expect(countdownPanel).toContain('记住值得期待与纪念的日子')
})
it('keeps single-page task counts without showing 1 / 1 pagination noise', () => {
expect(app).toContain("totalPages > 1 ? `第 ${page} / ${totalPages} 页 · 共 ${totalTasks} 项` : `共 ${totalTasks} 项`")
})
it('groups Today into task, habit, and countdown reminder blocks', () => {
expect(app).toContain("<section :class=\"{ 'today-block': activeView==='today' }\">")
expect(app.match(/class=\"today-block\"/g)?.length).toBe(2)
expect(app).toContain('今日任务')
expect(app).toContain('今日习惯')
expect(app).toContain('倒数日提醒')
expect(countdownPanel).toContain("const props = defineProps<{ view?: 'full' | 'today' }>()")
expect(css).toContain('.today-block{')
})
it('reduces task row noise to a priority dot and desktop-only delete action', () => {
expect(app).toContain('class="priority-dot"')
expect(app).not.toContain("{{['','低','中','高'][node.task.priority]}}")
expect(app).toContain('class="icon ghost task-delete"')
expect(css).toContain('.priority-dot')
expect(css).toMatch(/\.task-delete\{display:none/)
})
it('uses one mobile overflow trigger for sidebar row actions and folds archived lists by default', () => {
expect(app).toContain("const sidebarActionMenu = ref('')")
expect(app).toContain('class="mini-icon sidebar-more mobile-only"')
expect(app).toContain('class="sidebar-row-menu"')
expect(app).toContain('const archivedListsOpen = ref(false)')
expect(app).toContain('v-if="archivedListsOpen"')
expect(css).toContain('.sidebar-row-menu{')
expect(css).toMatch(/\.row-actions\{display:none[^}]*\}/)
})
})
describe('desktop sidebar collapse & folder scrollbar', () => { describe('desktop sidebar collapse & folder scrollbar', () => {
it('keeps overlay scrims out of the desktop grid layout', () => { it('keeps overlay scrims out of the desktop grid layout', () => {
expect(css).toContain('.scrim{display:none}') expect(css).toContain('.scrim{display:none}')