feat: swipe complete, list archive, today habits and calendar subscriptions
ci / docker (push) Successful in 3m20s
ci / docker (push) Successful in 3m20s
This commit is contained in:
+94
-14
@@ -1,17 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
|
||||
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleHabitSwipe } from './lib/mvp-utils'
|
||||
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleRowSwipe } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
|
||||
type View = 'habits' | 'settings'
|
||||
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }>; stats?: Record<string, number> }
|
||||
type View = 'habits' | 'today-habits' | 'settings'
|
||||
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?:boolean; paused?:boolean; value: number | boolean }>; stats?: Record<string, number> }
|
||||
type CalendarSubscription = { id: string; name: string; url: string; color: string; enabled?: boolean }
|
||||
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
|
||||
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
|
||||
const props = defineProps<{ view: View }>()
|
||||
const emit = defineEmits<{ changed: []; notice: [message: string] }>()
|
||||
const habits = ref<Habit[]>([])
|
||||
const sessions = ref<Session[]>([])
|
||||
const audit = ref<any[]>([])
|
||||
const calendarSubscriptions = ref<CalendarSubscription[]>([])
|
||||
const todayEvents = ref<CalendarEvent[]>([])
|
||||
const calendarName = ref('合并日历')
|
||||
const calendarUrl = ref('https://cal.bboy.app/all.ics')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const habitName = ref('')
|
||||
@@ -23,6 +29,7 @@ const restoreFile = ref<File | null>(null)
|
||||
const numericValues = ref<Record<string, number | string>>({})
|
||||
const todayKey = ref(dateKey(new Date()))
|
||||
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const habitSwipeOffsets = ref<Record<string, number>>({})
|
||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function formatErrorMessage(detail: unknown): string {
|
||||
@@ -71,22 +78,41 @@ async function toggleHabit(h: Habit, day: string) {
|
||||
emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
|
||||
})
|
||||
}
|
||||
function isInteractiveTarget(target: EventTarget | null) {
|
||||
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
|
||||
}
|
||||
function startHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
if (h.kind === 'numeric' || busy.value) return
|
||||
if (h.kind === 'numeric' || busy.value || isInteractiveTarget(event.target)) return
|
||||
const touch = event.touches[0]
|
||||
if (touch) habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
|
||||
if (touch) {
|
||||
habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
|
||||
habitSwipeOffsets.value[h.id] = 0
|
||||
}
|
||||
}
|
||||
function moveHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
const start = habitSwipeStart.value
|
||||
const touch = event.touches[0]
|
||||
if (!start || start.id !== h.id || !touch) return
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) habitSwipeOffsets.value[h.id] = Math.min(deltaX, 92)
|
||||
}
|
||||
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
const start = habitSwipeStart.value
|
||||
habitSwipeStart.value = null
|
||||
habitSwipeOffsets.value[h.id] = 0
|
||||
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
|
||||
const touch = event.changedTouches[0]
|
||||
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
|
||||
if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
|
||||
await toggleHabit(h, todayKey.value)
|
||||
}
|
||||
}
|
||||
function cancelHabitSwipe() {
|
||||
function cancelHabitSwipe(h?: Habit) {
|
||||
habitSwipeStart.value = null
|
||||
if (h) habitSwipeOffsets.value[h.id] = 0
|
||||
}
|
||||
async function toggleHabitFromButton(h: Habit) {
|
||||
await toggleHabit(h, todayKey.value)
|
||||
}
|
||||
async function recordNumeric(h: Habit, day: string) {
|
||||
if (busy.value) return
|
||||
@@ -135,6 +161,50 @@ async function loadSettings() {
|
||||
audit.value = mergePage<any>(a).items
|
||||
})
|
||||
}
|
||||
async function loadCalendarTab() {
|
||||
await safe(async () => {
|
||||
const [subs] = await Promise.all([request('/calendar-subscriptions').catch(() => [])])
|
||||
calendarSubscriptions.value = (subs as CalendarSubscription[]) ?? []
|
||||
await loadTodayEvents()
|
||||
})
|
||||
}
|
||||
async function addCalendarSubscription() {
|
||||
if (!calendarUrl.value.trim()) return
|
||||
await safe(async () => {
|
||||
await request('/calendar-subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }),
|
||||
})
|
||||
await loadCalendarTab()
|
||||
emit('notice', '日历已订阅')
|
||||
})
|
||||
}
|
||||
async function removeCalendarSubscription(id: string) {
|
||||
if (!confirm('取消这个日历订阅?')) return
|
||||
await safe(async () => {
|
||||
await request(`/calendar-subscriptions/${id}`, { method: 'DELETE' })
|
||||
await loadCalendarTab()
|
||||
emit('notice', '已取消订阅')
|
||||
})
|
||||
}
|
||||
function habitTimeText(h: Habit) {
|
||||
return isDone(h, todayKey.value) ? '✓ 已完成' : '未完成'
|
||||
}
|
||||
async function loadTodayEvents() {
|
||||
if (!calendarSubscriptions.value.length) {
|
||||
todayEvents.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const events = await request(`/calendar-subscriptions/today?day=${todayKey.value}`) as CalendarEvent[]
|
||||
todayEvents.value = events ?? []
|
||||
} catch { todayEvents.value = [] }
|
||||
}
|
||||
function formatEventTime(item: CalendarEvent) {
|
||||
if (item.all_day) return '全天'
|
||||
const date = new Date(item.starts_at)
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
||||
}
|
||||
@@ -167,7 +237,7 @@ async function restore() {
|
||||
})
|
||||
}
|
||||
onMounted(() => {
|
||||
if (props.view === 'habits') {
|
||||
if (props.view === 'habits' || props.view === 'today-habits') {
|
||||
refreshHabitDay()
|
||||
void loadHabits()
|
||||
dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
|
||||
@@ -185,14 +255,23 @@ onBeforeUnmount(() => {
|
||||
<p v-if="error" class="inline-error">{{ error }}</p>
|
||||
|
||||
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||||
<template v-if="view === 'habits'">
|
||||
<template v-if="view === 'habits' || view === 'today-habits'">
|
||||
<header class="view-intro">
|
||||
<div><small>把想坚持的事,变成每天的日常</small><h2>习惯</h2></div>
|
||||
<div><small>{{ view === 'today-habits' ? '今天习惯单独看,一眼就知道还差什么' : '把想坚持的事,变成每天的日常' }}</small><h2>{{ view === 'today-habits' ? '今天的习惯' : '习惯' }}</h2></div>
|
||||
<button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button>
|
||||
</header>
|
||||
|
||||
<!-- 新建习惯 -->
|
||||
<form class="habit-create" @submit.prevent="addHabit">
|
||||
<!-- 今日习惯:只展示“今天该做”的习惯 -->
|
||||
<div v-if="view === 'today-habits' && !busy" class="today-strip">
|
||||
<template v-for="h in habits.filter((item) => item.kind === 'boolean' ? !isDone(item, todayKey) : (item.cells ?? []).find((cell) => cell.day === todayKey && cell.scheduled) !== undefined)" :key="h.id">
|
||||
<button class="today-chip" :class="{ done: isDone(h, todayKey) }" :aria-label="isDone(h, todayKey) ? `完成${h.name}打卡` : `打卡${h.name}`" @click="toggleHabitFromButton(h)"><Repeat2 />{{ h.name }}</button>
|
||||
</template>
|
||||
<div v-if="!habits.some((item) => item.kind === 'boolean' ? !isDone(item, todayKey) : (item.cells ?? []).find((cell) => cell.day === todayKey && cell.scheduled) !== undefined)" class="empty-panel">今天的习惯都完成了 🎉</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -205,10 +284,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 习惯列表:布尔习惯整行右滑打卡,数值习惯保留明确输入记录 -->
|
||||
<div class="habit-list">
|
||||
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric' }" @touchstart.passive="startHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe">
|
||||
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: (habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||
<span v-if="h.kind !== 'numeric'" class="swipe-bg">✓ 打卡</span>
|
||||
<div v-if="h.kind !== 'numeric'" class="habit-main">
|
||||
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 右滑可取消' : '右滑整行打卡' }}</small></span>
|
||||
<span class="habit-swipe-hint" aria-hidden="true">{{ isDone(h, todayKey) ? '已完成' : '右滑' }}</span>
|
||||
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ isDone(h, todayKey) ? '已完成' : '打卡' }}</button>
|
||||
</div>
|
||||
<div v-else class="habit-main numeric-habit">
|
||||
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
|
||||
|
||||
Reference in New Issue
Block a user