Files
dodo/frontend/src/MvpPanel.vue
T
bboysoul 98578b4f36
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s
perf: streamline view data loading
2026-09-10 08:06:52 +08:00

615 lines
37 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, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
import { moveItemWithinScope } from './lib/task-utils'
import { changedHabitFields, dateKey, formatHabitApiError, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
type View = 'habits' | 'today-habits' | 'settings'
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; schedule_type?: HabitFormValues['schedule_type']; weekdays?: number[] | null; month_days?: number[] | null; interval_days?: number | null; archived_at?: string | null; unit?: string; cells?: HabitCell[]; 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; showCompleted: boolean }>()
const emit = defineEmits<{
changed: []
notice: [message: string]
summary: [value: { total: number; completed: number }]
'update:showCompleted': [value: boolean]
}>()
const habits = ref<Habit[]>([])
const archivedHabits = ref<Habit[]>([])
const showArchivedHabits = ref(false)
const archivedHabitsLoaded = ref(false)
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 habitMax = ref<number | null>(null)
const habitSchedule = ref<HabitFormValues['schedule_type']>('daily')
const habitWeekdays = ref<number[]>([])
const habitMonthDaysText = ref('')
const habitIntervalDays = ref(1)
const habitErrors = ref<HabitFormErrors>({})
const habitFormError = ref('')
const habitSubmitted = ref(false)
const habitComposerOpen = ref(false)
const editingHabit = ref<Habit | null>(null)
const originalHabitForm = ref<HabitFormValues | null>(null)
const selectedHabit = ref<Habit | null>(null)
const habitDetailClickSuppressed = ref(false)
const habitDetailSheet = ref<HTMLElement | null>(null)
let habitDetailOpener: HTMLElement | null = null
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 restoreFile = ref<File | null>(null)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordBusy = ref(false)
const passwordError = ref('')
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 habitReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
const habitReorderTarget = ref('')
const justCompletedHabitIds = ref(new Set<string>())
const markHabitJustCompleted = createCompletionPulse(
(id) => { justCompletedHabitIds.value = new Set(justCompletedHabitIds.value).add(id) },
(id) => { const next = new Set(justCompletedHabitIds.value); next.delete(id); justCompletedHabitIds.value = next },
)
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value : todayHabits.value.filter((item) => !isDone(item, todayKey.value)))
const visibleHabits = computed(() => props.showCompleted ? habits.value : habits.value.filter((item) => !isDone(item, todayKey.value)))
const todayHabitSummary = computed(() => ({
total: todayHabits.value.length,
completed: todayHabits.value.filter((item) => isDone(item, todayKey.value)).length,
}))
watch(todayHabitSummary, (value) => emit('summary', value), { immediate: true })
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
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(formatHabitApiError((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 habitAction(h: Habit) { return habitActionState(logFor(h, todayKey.value), Boolean(h.archived_at)) }
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 startHabitReorder(h: Habit, event: PointerEvent) {
if (busy.value || !props.showCompleted) return
habitReorder.value = { id: h.id, startY: event.clientY, offsetY: 0 }
habitReorderTarget.value = h.id
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
}
function moveHabitReorder(h: Habit, event: PointerEvent) {
const drag = habitReorder.value
if (!drag || drag.id !== h.id) return
drag.offsetY = event.clientY - drag.startY
const handle = event.currentTarget as HTMLElement
const row = document.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-habit-id]'))
.find((element) => element && element !== handle.closest('[data-habit-id]'))
if (row?.dataset.habitId) habitReorderTarget.value = row.dataset.habitId
}
async function finishHabitReorder(h: Habit, event: PointerEvent) {
const drag = habitReorder.value
const targetId = habitReorderTarget.value
habitReorder.value = null
habitReorderTarget.value = ''
if (!drag || drag.id !== h.id || !targetId || targetId === h.id) return
const placement = event.clientY >= drag.startY ? 'after' : 'before'
const previous = habits.value
const next = moveItemWithinScope(previous, h.id, targetId, placement)
if (next === previous) return
habits.value = next
writeHabitGridCache(dateKey(new Date()), next)
try {
await request('/habits/reorder', { method: 'PUT', body: JSON.stringify({ habit_ids: next.map((item) => item.id) }) })
emit('notice', '顺序已保存')
} catch (e) {
habits.value = previous
writeHabitGridCache(dateKey(new Date()), previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
function cancelHabitReorder() {
habitReorder.value = null
habitReorderTarget.value = ''
}
function startHabitSwipe(h: Habit, event: TouchEvent) {
if (busy.value || !habitAction(h).writable || 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 || !habitAction(h).writable || 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}`
}
function habitProgressMax(h: Habit) {
return Math.max(Number(h.target ?? 1), 1)
}
function habitProgressValue(h: Habit) {
return Math.min(Math.max(Number(logFor(h, todayKey.value)?.value ?? 0), 0), habitProgressMax(h))
}
function habitWeekday(day: string) {
const [year, month, date] = day.split('-').map(Number)
return new Intl.DateTimeFormat('zh-CN', { weekday: 'narrow' }).format(new Date(year, month - 1, date))
}
function habitCellDone(h: Habit, cell: NonNullable<Habit['cells']>[number]) {
return isHabitComplete(h.kind, cell.value, h.target ?? 1)
}
function habitCellLabel(h: Habit, cell: NonNullable<Habit['cells']>[number]) {
const state = !cell.scheduled ? '未安排' : cell.paused ? '已暂停' : habitCellDone(h, cell) ? '已完成' : '未完成'
return `${cell.day} ${habitWeekday(cell.day)}${state}`
}
function setLocalHabitValue(h: Habit, next: number | boolean) {
habits.value = habits.value.map((item) => item.id !== h.id
? item
: {
...item,
cells: (item.cells ?? []).map((cell) => cell.day === todayKey.value ? { ...cell, value: next } : cell),
})
}
async function applyHabitSwipe(h: Habit, deltaX: number) {
if (!habitAction(h).writable) return
const current = logFor(h, todayKey.value)?.value
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
const next = deltaX > 0
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
: previousHabitSwipeValue(h.kind, current)
const previous = current ?? 0
setLocalHabitValue(h, next)
try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
if (!wasDone && isHabitComplete(h.kind, next, h.target ?? 1)) markHabitJustCompleted(h.id)
emit('notice', next > Number(previous) ? '已记录一次 🎉' : '已减少一次')
} catch (e) {
setLocalHabitValue(h, previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
function suppressHabitDetailClick() {
habitDetailClickSuppressed.value = true
window.setTimeout(() => { habitDetailClickSuppressed.value = false }, 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)) {
suppressHabitDetailClick()
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)) {
suppressHabitDetailClick()
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) {
if (!habitAction(h).writable) return
const current = logFor(h, todayKey.value)?.value
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
const previous = current ?? 0
const next = habitButtonValue(h.kind, current, h.target ?? 1)
setLocalHabitValue(h, next)
try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
if (!wasDone && isHabitComplete(h.kind, next, h.target ?? 1)) markHabitJustCompleted(h.id)
emit('notice', habitButtonNotice(h.kind, previous, next))
} catch (e) {
setLocalHabitValue(h, previous)
error.value = e instanceof Error ? e.message : '请求失败'
}
}
function currentHabitForm(): HabitFormValues {
return {
name: habitName.value,
kind: habitType.value,
target: habitType.value === 'numeric' ? Number(habitTarget.value) : 1,
max_value: habitType.value === 'numeric' ? habitMax.value : 1,
schedule_type: habitSchedule.value,
weekdays: habitSchedule.value === 'weekly' ? habitWeekdays.value : null,
month_days: habitSchedule.value === 'monthly' ? habitMonthDaysText.value.split(/[,\s]+/).filter(Boolean).map(Number) : null,
interval_days: habitSchedule.value === 'interval' ? Number(habitIntervalDays.value) : null,
}
}
function refreshHabitErrors() {
if (habitSubmitted.value) habitErrors.value = validateHabitForm(currentHabitForm())
}
const habitFormInvalid = computed(() => Object.keys(validateHabitForm(currentHabitForm())).length > 0)
const habitComposerTitle = computed(() => editingHabit.value ? '编辑习惯' : '添加习惯')
watch([habitName, habitType, habitTarget, habitMax, habitSchedule, habitWeekdays, habitMonthDaysText, habitIntervalDays], refreshHabitErrors, { deep: true })
async function saveHabit() {
habitSubmitted.value = true
habitFormError.value = ''
const form = currentHabitForm()
habitErrors.value = validateHabitForm(form)
if (Object.keys(habitErrors.value).length) {
void nextTick(() => document.querySelector<HTMLElement>('.habit-compose-sheet [aria-invalid="true"]')?.focus())
return
}
const normalized = { ...form, name: form.name.trim() }
busy.value = true
try {
if (editingHabit.value && originalHabitForm.value) {
const changes = changedHabitFields(originalHabitForm.value, normalized)
if (Object.keys(changes).length) await request(`/habits/${editingHabit.value.id}`, { method: 'PATCH', body: JSON.stringify(changes) })
emit('notice', Object.keys(changes).length ? '习惯已更新' : '习惯未修改')
} else {
await request('/habits', { method: 'POST', body: JSON.stringify(normalized) })
emit('notice', '习惯已创建')
}
habitComposerOpen.value = false
editingHabit.value = null
originalHabitForm.value = null
await loadHabits()
} catch (reason) {
habitFormError.value = reason instanceof Error ? reason.message : '请求失败'
} finally {
busy.value = false
}
}
function resetHabitForm() {
habitName.value = ''
habitType.value = 'boolean'
habitTarget.value = 1
habitMax.value = null
habitSchedule.value = 'daily'
habitWeekdays.value = []
habitMonthDaysText.value = ''
habitIntervalDays.value = 1
habitErrors.value = {}
habitFormError.value = ''
habitSubmitted.value = false
}
function openHabitComposer(origin?: { x: number; y: number }) {
if (origin) habitComposeOrigin.value = origin
editingHabit.value = null
originalHabitForm.value = null
resetHabitForm()
habitComposerOpen.value = true
void nextTick(() => habitNameInput.value?.focus())
}
function editHabit(h: Habit) {
editingHabit.value = h
habitName.value = h.name
habitType.value = h.kind === 'numeric' ? 'numeric' : 'boolean'
habitTarget.value = h.target ?? 1
habitMax.value = h.max_value ?? null
habitSchedule.value = h.schedule_type ?? 'daily'
habitWeekdays.value = [...(h.weekdays ?? [])]
habitMonthDaysText.value = (h.month_days ?? []).join(', ')
habitIntervalDays.value = h.interval_days ?? 1
habitErrors.value = {}
habitFormError.value = ''
habitSubmitted.value = false
originalHabitForm.value = currentHabitForm()
selectedHabit.value = null
habitComposerOpen.value = true
void nextTick(() => habitNameInput.value?.focus())
}
function closeHabitComposer() { habitComposerOpen.value = false; editingHabit.value = null; originalHabitForm.value = null }
function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
if (habitDetailClickSuppressed.value) return
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
selectedHabit.value = h
void nextTick(() => habitDetailSheet.value?.focus())
}
function closeHabitDetail() {
selectedHabit.value = null
void nextTick(() => habitDetailOpener?.focus())
}
defineExpose({ openHabitComposer })
async function archiveHabit(h: Habit) {
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
await safe(async () => {
await request(`/habits/${h.id}`, { method: 'DELETE' })
selectedHabit.value = null
await loadHabits()
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
emit('notice', '习惯已归档')
})
}
async function deleteHabit(h: Habit) {
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
await safe(async () => {
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
selectedHabit.value = null
await loadArchivedHabits()
emit('notice', '习惯已永久删除')
})
}
async function loadArchivedHabits() {
archivedHabits.value = await request('/habits?archived=true') as Habit[]
archivedHabitsLoaded.value = true
}
async function toggleArchivedHabits() {
showArchivedHabits.value = !showArchivedHabits.value
if (showArchivedHabits.value && !archivedHabitsLoaded.value) await safe(loadArchivedHabits)
}
function refreshHabitDay() {
const next = dateKey(new Date())
if (next !== todayKey.value) {
todayKey.value = next
if (props.view === 'habits') void loadHabits()
}
}
async function loadHabits() {
const week = dateKey(new Date())
const cached = readHabitGridCache<Habit>(week)
if (cached) habits.value = cached
if (!cached) busy.value = true
error.value = ''
try {
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
habits.value = data.habits ?? []
writeHabitGridCache(week, habits.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '请求失败'
} finally {
if (!cached) busy.value = false
}
}
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 () => {
const response = await fetch('/api/v1/export.csv', { credentials: 'include' })
if (!response.ok) throw new Error('导出失败')
downloadBlob(await response.blob(), 'dodo-export.csv')
})
}
async function restore() {
if (!restoreFile.value) return
if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return
await safe(async () => {
if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) {
const form = new FormData()
form.append('file', restoreFile.value!)
await request('/restore.csv?mode=merge', { method: 'POST', body: form })
} else {
const text = await restoreFile.value!.text()
await request('/restore?mode=merge', { method: 'POST', body: text })
}
emit('changed'); emit('notice', '数据已恢复')
})
}
async function changePassword() {
passwordError.value = ''
if (newPassword.value !== confirmPassword.value) {
passwordError.value = '两次输入的新密码不一致'
return
}
if (newPassword.value.length < 12) {
passwordError.value = '新密码至少需要 12 位'
return
}
passwordBusy.value = true
try {
await request('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword.value, new_password: newPassword.value }),
})
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
emit('notice', '密码已修改,其他设备已退出登录')
await loadSettings()
} catch (e) {
passwordError.value = e instanceof Error ? e.message : '修改密码失败'
} finally {
passwordBusy.value = false
}
}
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></div>
<div class="habit-toolbar"><label><input :checked="showCompleted" type="checkbox" @change="emit('update:showCompleted', ($event.target as HTMLInputElement).checked)"> 显示已完成</label></div>
</header>
<!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 -->
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row today-habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${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)">
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main">
<span class="habit-name">{{ h.name }}</span>
<small v-if="habitAction(h).reason" class="habit-state-note">{{ habitAction(h).reason }}</small>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
</div>
</article>
<div v-if="!visibleTodayHabits.length && !busy" class="empty-panel today-empty-panel"><span>{{ !showCompleted && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="showCompleted || !todayHabits.length" class="soft-button empty-action" @click="openHabitComposer"><Check/>添加习惯</button></div>
</div>
<!-- 完整习惯列表 -->
<Transition name="task-compose">
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer">
<form class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="saveHabit" @keydown.esc="closeHabitComposer">
<header class="app-sheet__header"><div><small>{{ editingHabit ? 'EDIT HABIT' : 'NEW HABIT' }}</small><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
<div class="app-sheet__body">
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
<label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称" :aria-invalid="Boolean(habitErrors.name)" aria-describedby="habit-name-error"><small v-if="habitErrors.name" id="habit-name-error" class="field-error" role="alert">{{ habitErrors.name }}</small></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="目标值" :aria-invalid="Boolean(habitErrors.target)" aria-describedby="habit-target-error"><small v-if="habitErrors.target" id="habit-target-error" class="field-error" role="alert">{{ habitErrors.target }}</small></label><label v-if="habitType === 'numeric'">最大值(可选)<input v-model.number="habitMax" type="number" min="0" step="any" aria-label="最大值" :aria-invalid="Boolean(habitErrors.max_value)" aria-describedby="habit-max-error"><small v-if="habitErrors.max_value" id="habit-max-error" class="field-error" role="alert">{{ habitErrors.max_value }}</small></label></div>
<label>计划类型<select v-model="habitSchedule" aria-label="计划类型"><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="interval">间隔</option></select></label>
<fieldset v-if="habitSchedule === 'weekly'" class="habit-weekdays" aria-describedby="habit-weekdays-error"><legend>每周日期</legend><label v-for="(label, day) in ['一','二','三','四','五','六','日']" :key="day"><input v-model="habitWeekdays" type="checkbox" :value="day">周{{ label }}</label><small v-if="habitErrors.weekdays" id="habit-weekdays-error" class="field-error" role="alert">{{ habitErrors.weekdays }}</small></fieldset>
<label v-if="habitSchedule === 'monthly'">每月日期<input v-model="habitMonthDaysText" inputmode="numeric" placeholder="例如:1, 15, 31" :aria-invalid="Boolean(habitErrors.month_days)" aria-describedby="habit-month-days-error"><small v-if="habitErrors.month_days" id="habit-month-days-error" class="field-error" role="alert">{{ habitErrors.month_days }}</small></label>
<label v-if="habitSchedule === 'interval'">间隔天数<input v-model.number="habitIntervalDays" type="number" min="1" step="1" :aria-invalid="Boolean(habitErrors.interval_days)" aria-describedby="habit-interval-error"><small v-if="habitErrors.interval_days" id="habit-interval-error" class="field-error" role="alert">{{ habitErrors.interval_days }}</small></label>
</div>
<footer class="app-sheet__footer"><span v-if="habitFormInvalid" class="field-error" role="status">请修正表单中的错误后再保存</span><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="busy || habitFormInvalid">{{ busy ? '保存中…' : editingHabit ? '保存修改' : '添加习惯' }}</button></footer>
</form>
</div>
</Transition>
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 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)">
<button class="drag-handle habit-drag-handle" :disabled="!showCompleted" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`)" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
<span class="habit-name">{{ h.name }}</span>
<small v-if="habitAction(h).reason" class="habit-state-note">{{ habitAction(h).reason }}</small>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
<div class="habit-week" aria-label="最近一周打卡">
<span v-for="cell in h.cells" :key="cell.day" class="habit-week-cell" :class="{ done: habitCellDone(h, cell), unscheduled: !cell.scheduled || cell.paused, today: cell.day === todayKey }" :title="habitCellLabel(h, cell)" :aria-label="habitCellLabel(h, cell)">
<small class="habit-week-day">{{ habitWeekday(cell.day) }}</small><i></i>
</span>
</div>
</div>
</article>
<div v-if="!visibleHabits.length && !busy" class="empty-panel">{{ !showCompleted && habits.length ? '已完成的习惯已隐藏。' : '还没有习惯从一件容易坚持的小事开始。' }}</div>
<button class="archived-toggle" type="button" @click="toggleArchivedHabits"><ArchiveRestore/>{{ archivedHabitsLoaded ? `已归档(${archivedHabits.length}` : '已归档' }}</button>
<div v-if="showArchivedHabits" class="archived-habits">
<button v-for="h in archivedHabits" :key="h.id" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span>{{ h.name }}</span><small>查看详情</small></button>
<p v-if="archivedHabitsLoaded && !archivedHabits.length && !busy" class="empty-panel">暂无已归档习惯</p>
</div>
</div>
<Transition name="countdown-detail">
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
<div class="app-sheet__body"><div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div></div>
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
</article>
</div>
</Transition>
</template>
<!-- 设置与数据 -->
<template v-else>
<header class="view-intro">
<div><small>备份迁移与安全</small></div>
</header>
<div class="settings-grid">
<article class="tool-card"><FileJson /><h2>数据导出与恢复</h2><p>导出完整 CSV 数据或从 CSV / JSON 备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,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 password-card"><Activity /><h2>修改密码</h2><p>修改后当前设备保持登录其他设备会自动退出</p><form class="password-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></article>
<article class="tool-card wide"><LogOut /><h2>登录会话</h2><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 /><h2>最近活动</h2><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>