490 lines
28 KiB
Vue
490 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Trash2, X } from 'lucide-vue-next'
|
||
import { moveItemWithinScope } from './lib/task-utils'
|
||
import { dateKey, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, readStoredBoolean, shouldToggleRowSwipe, writeHabitGridCache, writeStoredBoolean } 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]
|
||
summary: [value: { total: number; completed: number }]
|
||
}>()
|
||
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 habitComposerOpen = ref(false)
|
||
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 HIDE_COMPLETED_HABITS_STORAGE_KEY = 'dodo.hide-completed-habits'
|
||
const hideCompletedHabits = ref(readStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, false))
|
||
const todayHabits = computed(() => habits.value.filter((item) => isHabitScheduledToday(item, todayKey.value)))
|
||
const visibleTodayHabits = computed(() => hideCompletedHabits.value ? todayHabits.value.filter((item) => !isDone(item, todayKey.value)) : todayHabits.value)
|
||
const visibleHabits = computed(() => hideCompletedHabits.value ? habits.value.filter((item) => !isDone(item, todayKey.value)) : habits.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 })
|
||
watch(hideCompletedHabits, (value) => writeStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, 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 startHabitReorder(h: Habit, event: PointerEvent) {
|
||
if (busy.value || hideCompletedHabits.value) 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 || 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}`
|
||
}
|
||
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 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) {
|
||
const current = logFor(h, todayKey.value)?.value
|
||
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 }) })
|
||
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) {
|
||
const current = logFor(h, todayKey.value)?.value
|
||
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 }) })
|
||
emit('notice', habitButtonNotice(h.kind, previous, next))
|
||
} catch (e) {
|
||
setLocalHabitValue(h, previous)
|
||
error.value = e instanceof Error ? e.message : '请求失败'
|
||
}
|
||
}
|
||
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 = ''
|
||
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 }
|
||
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()
|
||
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() {
|
||
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><h2>习惯</h2></div>
|
||
<div class="habit-toolbar"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
|
||
</header>
|
||
|
||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
||
<div class="habit-toolbar habit-toolbar-today"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
|
||
<article v-for="h in visibleTodayHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 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="hideCompletedHabits" 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" :aria-label="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="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>{{ hideCompletedHabits && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="!hideCompletedHabits || !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" @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>
|
||
|
||
|
||
<!-- 习惯列表支持整行滑动记录。 -->
|
||
<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), 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="hideCompletedHabits" 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" :aria-label="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="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="!habits.length && !busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div>
|
||
</div>
|
||
<Transition name="countdown-detail">
|
||
<div v-if="selectedHabit" class="habit-detail-mask" @click.self="closeHabitDetail">
|
||
<article ref="habitDetailSheet" class="habit-detail-sheet" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
||
<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="habit-detail-progress"><span>今日进度</span><strong>{{ habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||
<footer><button type="button" class="danger-text" @click="archiveHabit(selectedHabit)"><Trash2/>归档习惯</button></footer>
|
||
</article>
|
||
</div>
|
||
</Transition>
|
||
</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>导出完整 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 /><h3>修改密码</h3><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 /><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>
|