615 lines
37 KiB
Vue
615 lines
37 KiB
Vue
<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>
|