324 lines
18 KiB
Vue
324 lines
18 KiB
Vue
<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 { dateKey, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils'
|
||
import { csrfHeader } from './lib/csrf'
|
||
|
||
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 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 busy = ref(false)
|
||
const error = ref('')
|
||
const habitName = ref('')
|
||
const habitType = ref<'boolean' | 'numeric'>('boolean')
|
||
const habitTarget = ref(1)
|
||
const importFile = ref<File | null>(null)
|
||
const importPreview = ref<any>(null)
|
||
const restoreFile = ref<File | null>(null)
|
||
const todayKey = ref(dateKey(new Date()))
|
||
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||
const habitPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||
const habitSwipeOffsets = ref<Record<string, number>>({})
|
||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||
|
||
function formatErrorMessage(detail: unknown): string {
|
||
if (typeof detail === 'string') return detail
|
||
if (Array.isArray(detail)) {
|
||
const text = detail
|
||
.map((item) => (item && typeof item === 'object' && 'msg' in item && typeof (item as { msg?: unknown }).msg === 'string' ? (item as { msg: string }).msg : String(item)))
|
||
.join(';')
|
||
return text || '请求参数有误'
|
||
}
|
||
if (detail && typeof detail === 'object') {
|
||
const detailObject = detail as Record<string, unknown>
|
||
if ('detail' in detailObject) return formatErrorMessage(detailObject.detail)
|
||
return JSON.stringify(detail)
|
||
}
|
||
return '请求失败'
|
||
}
|
||
|
||
async function request(path: string, options: RequestInit = {}) {
|
||
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
|
||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
|
||
const csrf = csrfHeader(options.method)
|
||
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
|
||
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
|
||
if (!response.ok) {
|
||
const body = await response.json().catch(() => ({}))
|
||
throw new Error(formatErrorMessage((body as { detail?: unknown }).detail))
|
||
}
|
||
const type = response.headers.get('content-type') || ''
|
||
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob()
|
||
}
|
||
async function safe(work: () => Promise<void>) {
|
||
busy.value = true; error.value = ''
|
||
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : '请求失败' } finally { busy.value = false }
|
||
}
|
||
|
||
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
|
||
function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
|
||
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 (busy.value || isInteractiveTarget(event.target)) return
|
||
const touch = event.touches[0]
|
||
if (touch) {
|
||
habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
|
||
habitSwipeOffsets.value[h.id] = 0
|
||
}
|
||
}
|
||
function startHabitPointer(h: Habit, event: PointerEvent) {
|
||
if (busy.value || isInteractiveTarget(event.target)) return
|
||
if (event.pointerType === 'touch') return
|
||
habitPointerStart.value = { id: h.id, x: event.clientX, y: event.clientY }
|
||
}
|
||
function moveHabitPointer(h: Habit, event: PointerEvent) {
|
||
const start = habitPointerStart.value
|
||
if (!start || start.id !== h.id) return
|
||
const deltaX = event.clientX - start.x
|
||
const deltaY = event.clientY - start.y
|
||
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
|
||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||
const canIncrement = deltaX > 0 && current < (h.target ?? 1)
|
||
const canDecrement = deltaX < 0 && current > 0
|
||
if (canIncrement || canDecrement) {
|
||
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
|
||
}
|
||
}
|
||
}
|
||
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
|
||
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
|
||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||
const canIncrement = deltaX > 0 && current < (h.target ?? 1)
|
||
const canDecrement = deltaX < 0 && current > 0
|
||
if (canIncrement || canDecrement) {
|
||
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
|
||
}
|
||
}
|
||
}
|
||
function habitProgressText(h: Habit) {
|
||
if (h.kind !== 'numeric') return ''
|
||
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
|
||
}
|
||
|
||
async function applyHabitSwipe(h: Habit, deltaX: number) {
|
||
const current = logFor(h, todayKey.value)?.value
|
||
const next = deltaX > 0
|
||
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
|
||
: previousHabitSwipeValue(h.kind, current)
|
||
await safe(async () => {
|
||
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||
await loadHabits()
|
||
emit('notice', next > Number(current ?? 0) ? '已记录一次 🎉' : '已减少一次')
|
||
})
|
||
}
|
||
|
||
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 || busy.value) return
|
||
const touch = event.changedTouches[0]
|
||
const deltaX = touch ? touch.clientX - start.x : 0
|
||
const deltaY = touch ? touch.clientY - start.y : 0
|
||
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
|
||
const expectedDirection = h.kind === 'numeric'
|
||
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
|
||
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
|
||
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||
await applyHabitSwipe(h, deltaX)
|
||
}
|
||
}
|
||
async function finishHabitPointer(h: Habit, event: PointerEvent) {
|
||
const start = habitPointerStart.value
|
||
if (!start || start.id !== h.id) return
|
||
habitPointerStart.value = null
|
||
habitSwipeOffsets.value[h.id] = 0
|
||
const deltaX = event.clientX - start.x
|
||
const deltaY = event.clientY - start.y
|
||
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
|
||
const expectedDirection = h.kind === 'numeric'
|
||
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
|
||
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
|
||
if (expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||
void applyHabitSwipe(h, deltaX)
|
||
}
|
||
}
|
||
function cancelHabitPointer(h?: Habit) {
|
||
habitPointerStart.value = null
|
||
if (h) habitSwipeOffsets.value[h.id] = 0
|
||
}
|
||
function cancelHabitSwipe(h?: Habit) {
|
||
habitSwipeStart.value = null
|
||
if (h) habitSwipeOffsets.value[h.id] = 0
|
||
}
|
||
async function toggleHabitFromButton(h: Habit) {
|
||
const current = logFor(h, todayKey.value)?.value
|
||
const next = isDone(h, todayKey.value)
|
||
? previousHabitSwipeValue(h.kind, current)
|
||
: nextHabitSwipeValue(h.kind, current, h.target ?? 1)
|
||
await safe(async () => {
|
||
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
|
||
await loadHabits()
|
||
emit('notice', next > Number(current ?? 0) ? '已记录一次 🎉' : '已减少一次')
|
||
})
|
||
}
|
||
async function addHabit() {
|
||
if (!habitName.value.trim()) return
|
||
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 = ''
|
||
await loadHabits()
|
||
emit('notice', '习惯已创建')
|
||
})
|
||
}
|
||
async function archiveHabit(h: Habit) {
|
||
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
||
await safe(async () => {
|
||
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||
await loadHabits()
|
||
emit('notice', '习惯已归档')
|
||
})
|
||
}
|
||
function refreshHabitDay() {
|
||
const next = dateKey(new Date())
|
||
if (next !== todayKey.value) {
|
||
todayKey.value = next
|
||
if (props.view === 'habits') void loadHabits()
|
||
}
|
||
}
|
||
async function loadHabits() {
|
||
await safe(async () => {
|
||
const data = await request(`/habits/grid?week=${dateKey(new Date())}`) as { habits?: Habit[] }
|
||
habits.value = data.habits ?? []
|
||
})
|
||
}
|
||
async function loadSettings() {
|
||
await safe(async () => {
|
||
const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])])
|
||
sessions.value = mergePage<Session>(s).items
|
||
audit.value = mergePage<any>(a).items
|
||
})
|
||
}
|
||
async function revoke(id: string) {
|
||
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
||
}
|
||
function downloadBlob(blob: Blob, name: string) {
|
||
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||
}
|
||
async function exportData() { await safe(async () => downloadBlob(await request('/export') as Blob, 'dodo-export.json')) }
|
||
async function previewImport() {
|
||
if (!importFile.value) return
|
||
await safe(async () => {
|
||
const form = new FormData(); form.append('file', importFile.value!)
|
||
importPreview.value = await request('/import/ticktick/preview', { method: 'POST', body: form })
|
||
})
|
||
}
|
||
async function confirmImport() {
|
||
await safe(async () => {
|
||
const form = new FormData(); form.append('file', importFile.value!)
|
||
const result = await request('/import/ticktick', { method: 'POST', body: form }) as { imported?: number; skipped?: number }
|
||
importPreview.value = null
|
||
emit('changed'); emit('notice', `导入完成:新增 ${result?.imported ?? 0},跳过 ${result?.skipped ?? 0}`)
|
||
})
|
||
}
|
||
async function restore() {
|
||
if (!restoreFile.value) return
|
||
if (!confirm('恢复为合并模式,将导入 JSON 中的清单与任务。继续吗?')) return
|
||
await safe(async () => {
|
||
const text = await restoreFile.value!.text()
|
||
await request('/restore?mode=merge', { method: 'POST', body: text })
|
||
emit('changed'); emit('notice', '数据已恢复')
|
||
})
|
||
}
|
||
onMounted(() => {
|
||
if (props.view === 'habits' || props.view === 'today-habits') {
|
||
refreshHabitDay()
|
||
void loadHabits()
|
||
dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
|
||
} else {
|
||
void loadSettings()
|
||
}
|
||
})
|
||
onBeforeUnmount(() => {
|
||
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section class="mvp-view" :class="{ loading: busy }">
|
||
<p v-if="error" class="inline-error">{{ error }}</p>
|
||
|
||
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||
<template v-if="view === 'habits' || view === 'today-habits'">
|
||
<header v-if="view === 'habits'" class="view-intro">
|
||
<div><small>把想坚持的事,变成每天的日常</small><h2>习惯</h2></div>
|
||
<button class="soft-button" @click="loadHabits"><RefreshCw />刷新</button>
|
||
</header>
|
||
|
||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||
<div v-if="view === 'today-habits' && !busy" class="habit-list today-habit-list">
|
||
<article v-for="h in todayHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||
<span class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? '− 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
|
||
<div class="habit-main">
|
||
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
||
<button class="sr-only" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" @click.stop="toggleHabitFromButton(h)">切换习惯状态</button>
|
||
</div>
|
||
</article>
|
||
<div v-if="!todayHabits.length" 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>
|
||
<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>
|
||
</form>
|
||
|
||
|
||
<!-- 习惯列表支持整行滑动记录。 -->
|
||
<div v-if="view === 'habits'" class="habit-list">
|
||
<article v-for="h in habits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||
<span class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? '− 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
|
||
<div class="habit-main">
|
||
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
||
<button class="sr-only" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">切换习惯状态</button>
|
||
</div>
|
||
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
|
||
</article>
|
||
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 设置与数据 -->
|
||
<template v-else>
|
||
<header class="view-intro">
|
||
<div><small>备份、迁移与安全</small><h2>设置与数据</h2></div>
|
||
</header>
|
||
<div class="settings-grid">
|
||
<article class="tool-card"><FileJson /><h3>数据导出与恢复</h3><p>下载完整 JSON 备份,或从备份恢复。</p><button class="soft-button" @click="exportData"><Download />导出 JSON</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept="application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
|
||
<article class="tool-card"><Upload /><h3>导入</h3><p>先预览变化,确认后才写入。</p><label class="file-button">选择文件<input type="file" accept=".json,.csv" @change="importFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button :disabled="!importFile" class="soft-button" @click="previewImport">生成预览</button><pre v-if="importPreview">{{ JSON.stringify(importPreview, null, 2) }}</pre><button v-if="importPreview" class="primary-small" @click="confirmImport">确认导入</button></article>
|
||
<article class="tool-card wide"><LogOut /><h3>登录会话</h3><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{ s.current ? '当前设备' : '其他设备' }}</b><small>{{ s.user_agent || '未知设备' }} · {{ s.last_seen_at || s.created_at }}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话。</p></article>
|
||
<article v-if="audit.length" class="tool-card wide"><Activity /><h3>最近活动</h3><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><span>{{ row.action || row.event || '变更' }}</span><small>{{ row.created_at || row.timestamp }}</small></div></article>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</template>
|