This commit is contained in:
+96
-3
@@ -6,7 +6,7 @@ import {
|
|||||||
Settings, Trash2, X, Repeat2, Ellipsis,
|
Settings, Trash2, X, Repeat2, Ellipsis,
|
||||||
} 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'
|
||||||
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 { csrfHeader } from './lib/csrf'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
@@ -49,7 +49,88 @@ const expandedFolders = ref(new Set<string>())
|
|||||||
const navigationLoaded = ref(false)
|
const navigationLoaded = ref(false)
|
||||||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
const taskSwipeStart = 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 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 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<HTMLInputElement>('.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() {
|
function toggleSidebar() {
|
||||||
const compact = window.matchMedia('(max-width: 930px)').matches
|
const compact = window.matchMedia('(max-width: 930px)').matches
|
||||||
@@ -245,7 +326,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; mobileMore.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 = []
|
||||||
@@ -495,7 +576,19 @@ 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>
|
<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>
|
<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" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
<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>
|
||||||
|
<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">
|
||||||
|
<header><div><small>NEW TASK</small><h2 id="task-compose-title">添加任务</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||||
|
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></label>
|
||||||
|
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div>
|
||||||
|
<label>截止时间<input v-model="composeDueAt" type="datetime-local"></label>
|
||||||
|
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||||
|
<footer><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
<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 v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||||
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
|
import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
|
||||||
|
|
||||||
describe('MVP view utilities', () => {
|
describe('MVP view utilities', () => {
|
||||||
it('formats a local date as YYYY-MM-DD', () => {
|
it('formats a local date as YYYY-MM-DD', () => {
|
||||||
@@ -81,4 +81,16 @@ describe('MVP view utilities', () => {
|
|||||||
expect(shouldToggleRowSwipe(30, 2)).toBe(false)
|
expect(shouldToggleRowSwipe(30, 2)).toBe(false)
|
||||||
expect(shouldToggleRowSwipe(80, 60)).toBe(false)
|
expect(shouldToggleRowSwipe(80, 60)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the draggable mobile add button inside the viewport', () => {
|
||||||
|
expect(clampFabPosition(-10, -20, 390, 844)).toEqual({ x: 14, y: 14 })
|
||||||
|
expect(clampFabPosition(500, 900, 390, 844)).toEqual({ x: 324, y: 718 })
|
||||||
|
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('distinguishes tapping the add button from dragging it', () => {
|
||||||
|
expect(isFabDrag(3, 4)).toBe(false)
|
||||||
|
expect(isFabDrag(8, 0)).toBe(true)
|
||||||
|
expect(isFabDrag(6, 7)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ export function nextTotalAfterLocalTaskAdd(total: number) {
|
|||||||
return Math.max(0, Number(total) || 0) + 1
|
return Math.max(0, Number(total) || 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 52, margin = 14, bottomReserved = 74) {
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)),
|
||||||
|
y: Math.min(Math.max(y, margin), Math.max(margin, viewportHeight - size - bottomReserved)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
|
||||||
|
return Math.hypot(deltaX, deltaY) >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
export function formatApiErrorDetail(detail: unknown): string {
|
export function formatApiErrorDetail(detail: unknown): string {
|
||||||
if (typeof detail === 'string') return detail
|
if (typeof detail === 'string') return detail
|
||||||
if (Array.isArray(detail)) {
|
if (Array.isArray(detail)) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -35,6 +35,17 @@ describe('mobile touch targets', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('mobile add task interaction', () => {
|
||||||
|
it('uses a draggable FAB and an animated add-task sheet', () => {
|
||||||
|
expect(app).toContain('@pointerdown="startFabDrag"')
|
||||||
|
expect(app).toContain('@pointermove="moveFab"')
|
||||||
|
expect(app).toContain('class="task-compose-mask"')
|
||||||
|
expect(app).toContain('class="task-compose-sheet"')
|
||||||
|
expect(css).toContain('.task-compose-enter-active')
|
||||||
|
expect(css).toContain('.fab.dragging')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('desktop sidebar collapse & folder scrollbar', () => {
|
describe('desktop sidebar collapse & folder scrollbar', () => {
|
||||||
it('lets the top menu collapse the desktop sidebar', () => {
|
it('lets the top menu collapse the desktop sidebar', () => {
|
||||||
expect(app).toContain('sidebar-collapsed')
|
expect(app).toContain('sidebar-collapsed')
|
||||||
|
|||||||
Reference in New Issue
Block a user