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"> <script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { import {
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, CirclePlus, Folder, ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
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, clampFabPosition, isFabDrag } from './lib/mvp-utils' import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe } 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'
import FloatingAddButton from './components/FloatingAddButton.vue'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } 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 activeList = ref('')
const activeView = ref<View>(defaultView()) const activeView = ref<View>(defaultView())
const selectedTask = ref<Task | null>(null) const selectedTask = ref<Task | null>(null)
const title = ref('')
const query = ref('') const query = ref('')
const error = ref('') const error = ref('')
const notice = ref('') const notice = ref('')
@@ -56,20 +56,12 @@ const composeListId = ref('')
const composeDueAt = ref('') const composeDueAt = ref('')
const composePriority = ref(0) const composePriority = ref(0)
const composeDescription = ref('') const composeDescription = ref('')
const fabDragging = ref(false) const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const fabPosition = ref<{ x: number; y: number } | null>(null) const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const fabPointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null) const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
let suppressTaskClickId = '' let suppressTaskClickId = ''
let suppressFabClick = false
const fabStyle = computed(() => fabPosition.value const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.value.x}px`, '--fab-origin-y': `${composeOrigin.value.y}px` }))
? { 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() { function openTaskCompose() {
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value 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()) nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
} }
function closeTaskCompose() { taskComposeOpen.value = false } 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() { async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim() const taskTitle = composeTitle.value.trim()
if (!taskTitle || !composeListId.value) return if (!taskTitle || !composeListId.value) return
@@ -102,37 +100,6 @@ async function submitTaskCompose() {
toast('任务已添加') toast('任务已添加')
} catch (reason) { fail(reason) } } 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
if (compact) { if (compact) {
@@ -181,7 +148,7 @@ const visibleTasks = computed(() => {
const now = new Date() const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7) const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value 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 === '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) 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 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 if (!isTaskView(view)) tasks.value = []
else await loadAll() 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() { async function loadTodayView() {
await loadAll() 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 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 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() { function previousPage() {
if (page.value <= 1 || loading.value) return if (page.value <= 1 || loading.value) return
page.value -= 1 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> <label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header> </header>
<template v-if="['habits','settings'].includes(activeView)"> <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> </template>
<CountdownPanel v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <template v-else>
<template v-if="activeView==='today'"> <template v-if="activeView==='today'">
<h3 class="section-heading"><ListTodo/>今日任务</h3> <h3 class="section-heading"><ListTodo/>今日任务</h3>
</template> </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 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> <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}"> <section class="task-list" :class="{loading}">
@@ -588,7 +544,7 @@ onMounted(bootstrap)
</section> </section>
<div v-if="activeView==='today'"> <div v-if="activeView==='today'">
<h3 class="section-heading"><Repeat2/>今日习惯</h3> <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> </div>
</template> </template>
</main> </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> <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" :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"> <Transition name="task-compose">
<div v-if="taskComposeOpen" class="task-compose-mask" @click.self="closeTaskCompose"> <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"> <form class="task-compose-sheet" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
+11 -8
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Plus, Trash2, X } from 'lucide-vue-next' import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail } from './lib/mvp-utils' import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail } from './lib/mvp-utils'
@@ -18,6 +18,8 @@ const editingId = ref<string|null>(null), error = ref('')
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', icon:'📅', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false }) const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', icon:'📅', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
const form = ref<Form>(freshForm()) const form = ref<Form>(freshForm())
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 titleInput = ref<HTMLInputElement | null>(null) const titleInput = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null let previousFocus: HTMLElement | null = null
@@ -72,7 +74,6 @@ async function request(path:string, options:RequestInit={}) {
} }
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } } async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
async function load() { await safe(async()=>{ const [a,b]=await Promise.all([request('/countdowns'),request('/countdowns?archived=true')]); items.value=a; archived.value=b }) } async function load() { await safe(async()=>{ const [a,b]=await Promise.all([request('/countdowns'),request('/countdowns?archived=true')]); items.value=a; archived.value=b }) }
function add() { onAdd() }
function edit(item:Countdown) { function edit(item:Countdown) {
editingId.value=item.id editingId.value=item.id
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, icon:item.icon, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year } form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, icon:item.icon, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
@@ -96,7 +97,8 @@ async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}${Number(m)}${Number(d)}`} function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}${Number(m)}${Number(d)}`}
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`} function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]} function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
function onAdd(){editingId.value=null;form.value=freshForm();open.value=true;focusDialog()} function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;editingId.value=null;form.value=freshForm();open.value=true;focusDialog()}
defineExpose({ openCountdownComposer })
onMounted(load) onMounted(load)
onBeforeUnmount(() => { previousFocus = null }) onBeforeUnmount(() => { previousFocus = null })
</script> </script>
@@ -104,7 +106,7 @@ onBeforeUnmount(() => { previousFocus = null })
<template> <template>
<section class="countdown-view" :class="{ loading:busy }"> <section class="countdown-view" :class="{ loading:busy }">
<div class="countdown-content" :inert="open" :aria-hidden="open ? 'true' : undefined"> <div class="countdown-content" :inert="open" :aria-hidden="open ? 'true' : undefined">
<header class="countdown-hero"><div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div><button class="countdown-add" @click="onAdd"><Plus/>添加日子</button></header> <header class="countdown-hero"><div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div></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 }">
@@ -113,13 +115,13 @@ onBeforeUnmount(() => { previousFocus = null })
<p><b>{{primaryDate(item)}}</b><span v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</span></p> <p><b>{{primaryDate(item)}}</b><span v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</span></p>
<div class="countdown-actions"><button v-if="!item.pinned" @click="pin(item)"><Pin/>置顶</button><button @click="edit(item)"><Pencil/>编辑</button><button @click="archiveItem(item)"><Archive/>归档</button></div> <div class="countdown-actions"><button v-if="!item.pinned" @click="pin(item)"><Pin/>置顶</button><button @click="edit(item)"><Pencil/>编辑</button><button @click="archiveItem(item)"><Archive/>归档</button></div>
</article> </article>
<button v-if="!items.length&&!busy" class="countdown-empty" @click="onAdd"><CalendarHeart/><b>添加第一个重要日子</b><span>生日纪念日或一场期待已久的旅行</span></button> <div v-if="!items.length&&!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>用右下角的圆圈添加生日纪念日旅行</span></div>
</div> </div>
<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>
<button class="fab countdown-fab mobile-only" aria-label="添加倒数日" @click="add"><Plus/></button>
</div> </div>
<div v-if="open" class="countdown-modal-mask" @click.self="closeDialog"><form class="countdown-modal" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus"> <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">
<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>
<label>历法<select v-model="form.calendar_mode"><option value="solar">{{calendarModeLabel('solar')}}</option><option value="lunar">{{calendarModeLabel('lunar')}}</option></select></label> <label>历法<select v-model="form.calendar_mode"><option value="solar">{{calendarModeLabel('solar')}}</option><option value="lunar">{{calendarModeLabel('lunar')}}</option></select></label>
@@ -135,5 +137,6 @@ onBeforeUnmount(() => { previousFocus = null })
<label>重复<select v-model="form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label> <label>重复<select v-model="form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
<footer><button type="button" class="secondary" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer> <footer><button type="button" class="secondary" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer>
</form></div> </form></div>
</Transition>
</section> </section>
</template> </template>
+26 -10
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next' import { Activity, ArchiveRestore, Download, FileJson, LogOut, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next'
import { dateKey, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils' import { dateKey, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
@@ -17,6 +17,10 @@ const error = ref('')
const habitName = ref('') const habitName = ref('')
const habitType = ref<'boolean' | 'numeric'>('boolean') const habitType = ref<'boolean' | 'numeric'>('boolean')
const habitTarget = ref(1) const habitTarget = ref(1)
const habitComposerOpen = ref(false)
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
const habitNameInput = ref<HTMLInputElement | null>(null)
const importFile = ref<File | null>(null) const importFile = ref<File | null>(null)
const importPreview = ref<any>(null) const importPreview = ref<any>(null)
const restoreFile = ref<File | null>(null) const restoreFile = ref<File | null>(null)
@@ -180,10 +184,21 @@ async function addHabit() {
await safe(async () => { await safe(async () => {
await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) }) await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) })
habitName.value = '' habitName.value = ''
habitComposerOpen.value = false
await loadHabits() await loadHabits()
emit('notice', '习惯已创建') emit('notice', '习惯已创建')
}) })
} }
function openHabitComposer(origin?: { x: number; y: number }) {
if (origin) habitComposeOrigin.value = origin
habitName.value = ''
habitType.value = 'boolean'
habitTarget.value = 1
habitComposerOpen.value = true
void nextTick(() => habitNameInput.value?.focus())
}
function closeHabitComposer() { habitComposerOpen.value = false }
defineExpose({ openHabitComposer })
async function archiveHabit(h: Habit) { async function archiveHabit(h: Habit) {
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
await safe(async () => { await safe(async () => {
@@ -282,15 +297,16 @@ onBeforeUnmount(() => {
<div v-else-if="view === 'today-habits' && busy" class="empty-panel">加载中</div> <div v-else-if="view === 'today-habits' && busy" class="empty-panel">加载中</div>
<!-- 完整习惯列表 --> <!-- 完整习惯列表 -->
<form v-if="view === 'habits'" class="habit-create" @submit.prevent="addHabit"> <Transition name="task-compose">
<input v-model="habitName" placeholder="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称"> <div v-if="habitComposerOpen" class="task-compose-mask" @click.self="closeHabitComposer">
<select v-model="habitType" aria-label="习惯类型"> <form class="task-compose-sheet habit-compose-sheet" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="addHabit" @keydown.esc="closeHabitComposer">
<option value="boolean">完成 / 未完成</option> <header><div><small>NEW HABIT</small><h2 id="habit-compose-title">添加习惯</h2></div><button class="icon" type="button" aria-label="关闭添加习惯" @click="closeHabitComposer"><X /></button></header>
<option value="numeric">按数量记录</option> <label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称"></label>
</select> <div class="task-compose-row"><label>记录方式<select v-model="habitType" aria-label="习惯类型"><option value="boolean">完成 / 未完成</option><option value="numeric">按数量记录</option></select></label><label v-if="habitType === 'numeric'">目标值<input v-model.number="habitTarget" type="number" min="0" step="any" aria-label="目标值"></label></div>
<input v-if="habitType === 'numeric'" v-model.number="habitTarget" type="number" min="0" step="any" placeholder="目标值" aria-label="目标值"> <footer><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="!habitName.trim()">添加习惯</button></footer>
<button class="primary-small"><Plus />添加</button>
</form> </form>
</div>
</Transition>
<!-- 习惯列表支持整行滑动记录 --> <!-- 习惯列表支持整行滑动记录 -->
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { clampFabPosition, isFabDrag } from '../lib/mvp-utils'
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' })
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
const dragging = ref(false)
const position = ref<{ x: number; y: number } | null>(null)
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
let suppressClick = false
const buttonStyle = computed(() => position.value
? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' }
: {})
function startDrag(event: PointerEvent) {
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
pointer.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 events */ }
}
function moveDrag(event: PointerEvent) {
const start = pointer.value
if (!start || start.id !== event.pointerId) return
const deltaX = event.clientX - start.startX
const deltaY = event.clientY - start.startY
if (!dragging.value && !isFabDrag(deltaX, deltaY)) return
dragging.value = true
position.value = clampFabPosition(start.originX + deltaX, start.originY + deltaY, window.innerWidth, window.innerHeight, 58, 14, 84)
}
function finishDrag(event: PointerEvent) {
const start = pointer.value
if (!start || start.id !== event.pointerId) return
const dragged = dragging.value || isFabDrag(event.clientX - start.startX, event.clientY - start.startY)
pointer.value = null
dragging.value = false
if (dragged) {
suppressClick = true
window.setTimeout(() => { suppressClick = false }, 180)
}
}
function activate(event: MouseEvent) {
if (suppressClick) return
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
emit('activate', { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })
}
</script>
<template>
<button class="unified-fab" :class="{ dragging }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<span class="unified-fab-aura" aria-hidden="true" />
<span class="unified-fab-core"><Plus /></span>
</button>
</template>
File diff suppressed because one or more lines are too long
+27 -10
View File
@@ -4,6 +4,8 @@ import { describe, expect, it } from 'vitest'
const css = readFileSync('src/style.css', 'utf8') const css = readFileSync('src/style.css', 'utf8')
const app = readFileSync('src/App.vue', 'utf8') const app = readFileSync('src/App.vue', 'utf8')
const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8') const mvpPanel = readFileSync('src/MvpPanel.vue', 'utf8')
const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8')
const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8')
describe('mobile navigation styles', () => { describe('mobile navigation styles', () => {
it('keeps the mobile More sheet visible when it is rendered', () => { it('keeps the mobile More sheet visible when it is rendered', () => {
@@ -39,8 +41,7 @@ describe('mobile touch targets', () => {
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;') expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toContain('.mini-icon,.row-actions button{min-width:44px;min-height:44px;') expect(css).toContain('.mini-icon,.row-actions button{min-width:44px;min-height:44px;')
expect(css).toContain('.bottom button{min-height:44px;') expect(css).toContain('.bottom button{min-height:44px;')
expect(css).toContain('.habit-create button{min-height:44px;') expect(css).toContain('.unified-fab{display:grid;place-items:center;')
expect(css).toContain('.countdown-add{min-height:44px;')
expect(css).toContain('.countdown-modal header button{min-width:44px;min-height:44px;') expect(css).toContain('.countdown-modal header button{min-width:44px;min-height:44px;')
}) })
@@ -50,14 +51,30 @@ describe('mobile touch targets', () => {
}) })
}) })
describe('mobile add task interaction', () => { describe('unified floating add interaction', () => {
it('uses a draggable FAB and an animated add-task sheet', () => { it('reuses one draggable FAB component for task, habit, and countdown views', () => {
expect(app).toContain('@pointerdown="startFabDrag"') expect(app).toContain("import FloatingAddButton from './components/FloatingAddButton.vue'")
expect(app).toContain('@pointermove="moveFab"') expect(app).toContain('<FloatingAddButton')
expect(app).toContain('class="task-compose-mask"') expect(floatingAdd).toContain('class="unified-fab"')
expect(app).toContain('class="task-compose-sheet"') expect(floatingAdd).toContain('@pointerdown="startDrag"')
expect(css).toContain('.task-compose-enter-active') expect(floatingAdd).toContain('@pointermove="moveDrag"')
expect(css).toContain('.fab.dragging') expect(floatingAdd).toContain("emit('activate',")
expect(css).toContain('.unified-fab.dragging')
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
expect(css).toContain('.countdown-compose-enter-active')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
})
it('removes inline create forms and opens the correct composer from the FAB', () => {
expect(app).not.toContain('class="quick"')
expect(mvpPanel).not.toContain('class="habit-create"')
expect(countdownPanel).not.toContain('class="countdown-add"')
expect(countdownPanel).not.toContain('class="fab countdown-fab')
expect(countdownPanel).not.toContain('@click="openCountdownComposer"')
expect(app).toContain("activeView==='habits'")
expect(app).toContain("activeView==='countdowns'")
expect(mvpPanel).toContain('openHabitComposer')
expect(countdownPanel).toContain('openCountdownComposer')
}) })
}) })