diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 66b9a62..b53e98f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@ import { 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 } from './lib/mvp-utils' +import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe, clampFabPosition, isFabDrag } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' @@ -49,7 +49,88 @@ const expandedFolders = ref(new Set()) const navigationLoaded = ref(false) const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null) const taskSwipeOffsets = ref>({}) +const taskComposeOpen = ref(false) +const composeTitle = ref('') +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) 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` } +}) + +function openTaskCompose() { + const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value + composeTitle.value = '' + composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId + composeDueAt.value = activeView.value === 'today' ? toDateTimeLocal(quickTaskFields('today', activeList.value, inboxId).due_at ?? null) : '' + composePriority.value = 0 + composeDescription.value = '' + taskComposeOpen.value = true + nextTick(() => document.querySelector('.task-compose-input')?.focus()) +} +function closeTaskCompose() { taskComposeOpen.value = false } +async function submitTaskCompose() { + const taskTitle = composeTitle.value.trim() + if (!taskTitle || !composeListId.value) return + try { + const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ + title: taskTitle, + list_id: composeListId.value, + due_at: fromDateTimeLocal(composeDueAt.value), + priority: composePriority.value, + description: composeDescription.value, + }) }) + if (isTaskView(activeView.value)) { + tasks.value.push(task) + totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value) + } + taskComposeOpen.value = false + selectTask(task) + 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 @@ -245,7 +326,7 @@ async function switchView(view: View, listId?: string) { activeView.value = view if (listId) activeList.value = listId page.value = 1 - selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false + selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false if (view === 'trash') await loadTrash() else if (view === 'today') await loadTodayView() else if (!isTaskView(view)) tasks.value = [] @@ -495,7 +576,19 @@ onMounted(bootstrap)
- + + +
+