feat: unify draggable creation button
ci / docker (push) Successful in 3m23s

This commit is contained in:
2026-09-07 12:04:34 +08:00
parent 373c038fe7
commit acab663226
6 changed files with 161 additions and 114 deletions
+18 -62
View File
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2, Ellipsis,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe, clampFabPosition, isFabDrag } from './lib/mvp-utils'
import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -29,7 +30,6 @@ const trash = ref<Task[]>([])
const activeList = ref('')
const activeView = ref<View>(defaultView())
const selectedTask = ref<Task | null>(null)
const title = ref('')
const query = ref('')
const error = ref('')
const notice = ref('')
@@ -56,20 +56,12 @@ const composeListId = ref('')
const composeDueAt = ref('')
const composePriority = ref(0)
const composeDescription = ref('')
const fabDragging = ref(false)
const fabPosition = ref<{ x: number; y: number } | null>(null)
const fabPointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
let suppressTaskClickId = ''
let suppressFabClick = false
const fabStyle = computed(() => fabPosition.value
? { left: `${fabPosition.value.x}px`, top: `${fabPosition.value.y}px`, right: 'auto', bottom: 'auto' }
: {})
const taskComposeStyle = computed(() => {
const centerX = (fabPosition.value?.x ?? window.innerWidth - 70) + 26
const centerY = (fabPosition.value?.y ?? window.innerHeight - 128) + 26
return { '--fab-origin-x': `${centerX}px`, '--fab-origin-y': `${centerY}px` }
})
const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.value.x}px`, '--fab-origin-y': `${composeOrigin.value.y}px` }))
function openTaskCompose() {
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
@@ -82,6 +74,12 @@ function openTaskCompose() {
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
}
function closeTaskCompose() { taskComposeOpen.value = false }
function activateFloatingAdd(origin: { x: number; y: number }) {
composeOrigin.value = origin
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim()
if (!taskTitle || !composeListId.value) return
@@ -102,37 +100,6 @@ async function submitTaskCompose() {
toast('任务已添加')
} catch (reason) { fail(reason) }
}
function startFabDrag(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
fabPointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top }
try { target.setPointerCapture?.(event.pointerId) } catch { /* pointer capture unavailable in synthetic/test events */ }
}
function moveFab(event: PointerEvent) {
const start = fabPointer.value
if (!start || start.id !== event.pointerId) return
const deltaX = event.clientX - start.startX
const deltaY = event.clientY - start.startY
if (!fabDragging.value && !isFabDrag(deltaX, deltaY)) return
fabDragging.value = true
fabPosition.value = clampFabPosition(start.originX + deltaX, start.originY + deltaY, window.innerWidth, window.innerHeight)
}
function finishFabDrag(event: PointerEvent) {
const start = fabPointer.value
if (!start || start.id !== event.pointerId) return
const dragged = fabDragging.value || isFabDrag(event.clientX - start.startX, event.clientY - start.startY)
fabPointer.value = null
fabDragging.value = false
if (dragged) {
suppressFabClick = true
window.setTimeout(() => { suppressFabClick = false }, 180)
}
}
function activateFab() {
if (suppressFabClick) return
openTaskCompose()
}
function toggleSidebar() {
const compact = window.matchMedia('(max-width: 930px)').matches
if (compact) {
@@ -181,7 +148,7 @@ const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['habits','settings'].includes(activeView.value)) return []
if (['habits','settings','countdowns'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
return query.value.trim() ? filterTasks(result, query.value) : result
@@ -333,15 +300,6 @@ async function switchView(view: View, listId?: string) {
else if (!isTaskView(view)) tasks.value = []
else await loadAll()
}
async function addTask() {
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
if (!title.value.trim() || !inboxId) return
try {
const fields = quickTaskFields(activeView.value, activeList.value, inboxId)
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), ...fields }) })
tasks.value.push(task); totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value); title.value = ''; selectTask(task); mobileDetail.value = true; toast('任务已添加')
} catch (reason) { fail(reason) }
}
async function loadTodayView() {
await loadAll()
}
@@ -501,7 +459,6 @@ async function restoreList(item: TaskList) {
}
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()) }
function previousPage() {
if (page.value <= 1 || loading.value) return
page.value -= 1
@@ -561,14 +518,13 @@ onMounted(bootstrap)
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" 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" />
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
</template>
<CountdownPanel v-else-if="activeView==='countdowns'" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<template v-if="activeView==='today'">
<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}">
@@ -588,7 +544,7 @@ onMounted(bootstrap)
</section>
<div v-if="activeView==='today'">
<h3 class="section-heading"><Repeat2/>今日习惯</h3>
<MvpPanel view="today-habits" @changed="refreshAll" @notice="toast" />
<MvpPanel ref="habitComposer" view="today-habits" @changed="refreshAll" @notice="toast" />
</div>
</template>
</main>
@@ -611,7 +567,7 @@ onMounted(bootstrap)
<div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航"><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:mobileMore||['tasks','upcoming','trash','settings'].includes(activeView)}" aria-haspopup="dialog" aria-controls="mobile-more-menu" :aria-expanded="mobileMore" @click="mobileMore=!mobileMore"><Ellipsis/><span>更多</span></button></nav>
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" :class="{dragging:fabDragging}" :style="fabStyle" aria-label="添加任务" @pointerdown="startFabDrag" @pointermove="moveFab" @pointerup="finishFabDrag" @pointercancel="finishFabDrag" @click="activateFab"><CirclePlus/></button>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
<Transition name="task-compose">
<div v-if="taskComposeOpen" class="task-compose-mask" @click.self="closeTaskCompose">
<form class="task-compose-sheet" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">