This commit is contained in:
+18
-62
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user