Files
dodo/frontend/src/MvpPanel.vue
T
2026-09-07 11:25:29 +08:00

324 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>