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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
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 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 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)
|
||||
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 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) {
|
||||
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 }
|
||||
@@ -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 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 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)
|
||||
onBeforeUnmount(() => { previousFocus = null })
|
||||
</script>
|
||||
@@ -104,7 +106,7 @@ onBeforeUnmount(() => { previousFocus = null })
|
||||
<template>
|
||||
<section class="countdown-view" :class="{ loading:busy }">
|
||||
<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>
|
||||
<div class="countdown-grid">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<button class="fab countdown-fab mobile-only" aria-label="添加倒数日" @click="add"><Plus/></button>
|
||||
</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>
|
||||
<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>
|
||||
@@ -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>
|
||||
<footer><button type="button" class="secondary" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer>
|
||||
</form></div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+26
-10
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
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 { csrfHeader } from './lib/csrf'
|
||||
|
||||
@@ -17,6 +17,10 @@ const error = ref('')
|
||||
const habitName = ref('')
|
||||
const habitType = ref<'boolean' | 'numeric'>('boolean')
|
||||
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 importPreview = ref<any>(null)
|
||||
const restoreFile = ref<File | null>(null)
|
||||
@@ -180,10 +184,21 @@ async function addHabit() {
|
||||
await safe(async () => {
|
||||
await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) })
|
||||
habitName.value = ''
|
||||
habitComposerOpen.value = false
|
||||
await loadHabits()
|
||||
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) {
|
||||
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
||||
await safe(async () => {
|
||||
@@ -282,15 +297,16 @@ onBeforeUnmount(() => {
|
||||
<div v-else-if="view === 'today-habits' && busy" class="empty-panel">加载中…</div>
|
||||
|
||||
<!-- 完整习惯列表 -->
|
||||
<form v-if="view === 'habits'" class="habit-create" @submit.prevent="addHabit">
|
||||
<input v-model="habitName" placeholder="新习惯名称(如:喝水 8 杯)" aria-label="新习惯名称">
|
||||
<select v-model="habitType" aria-label="习惯类型">
|
||||
<option value="boolean">完成 / 未完成</option>
|
||||
<option value="numeric">按数量记录</option>
|
||||
</select>
|
||||
<input v-if="habitType === 'numeric'" v-model.number="habitTarget" type="number" min="0" step="any" placeholder="目标值" aria-label="目标值">
|
||||
<button class="primary-small"><Plus />添加</button>
|
||||
<Transition name="task-compose">
|
||||
<div v-if="habitComposerOpen" class="task-compose-mask" @click.self="closeHabitComposer">
|
||||
<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">
|
||||
<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>
|
||||
<label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称"></label>
|
||||
<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>
|
||||
<footer><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="!habitName.trim()">添加习惯</button></footer>
|
||||
</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
@@ -4,6 +4,8 @@ import { describe, expect, it } from 'vitest'
|
||||
const css = readFileSync('src/style.css', 'utf8')
|
||||
const app = readFileSync('src/App.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', () => {
|
||||
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('.mini-icon,.row-actions button{min-width:44px;min-height:44px;')
|
||||
expect(css).toContain('.bottom button{min-height:44px;')
|
||||
expect(css).toContain('.habit-create button{min-height:44px;')
|
||||
expect(css).toContain('.countdown-add{min-height:44px;')
|
||||
expect(css).toContain('.unified-fab{display:grid;place-items:center;')
|
||||
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', () => {
|
||||
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('unified floating add interaction', () => {
|
||||
it('reuses one draggable FAB component for task, habit, and countdown views', () => {
|
||||
expect(app).toContain("import FloatingAddButton from './components/FloatingAddButton.vue'")
|
||||
expect(app).toContain('<FloatingAddButton')
|
||||
expect(floatingAdd).toContain('class="unified-fab"')
|
||||
expect(floatingAdd).toContain('@pointerdown="startDrag"')
|
||||
expect(floatingAdd).toContain('@pointermove="moveDrag"')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user