fix: harden habit input and lifecycle
This commit is contained in:
+144
-51
@@ -1,13 +1,14 @@
|
||||
<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 { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, 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 { changedHabitFields, dateKey, formatHabitApiError, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, readStoredBoolean, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, writeStoredBoolean, 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 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 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 }>()
|
||||
const emit = defineEmits<{
|
||||
@@ -16,6 +17,8 @@ const emit = defineEmits<{
|
||||
summary: [value: { total: number; completed: number }]
|
||||
}>()
|
||||
const habits = ref<Habit[]>([])
|
||||
const archivedHabits = ref<Habit[]>([])
|
||||
const showArchivedHabits = ref(false)
|
||||
const sessions = ref<Session[]>([])
|
||||
const audit = ref<any[]>([])
|
||||
const busy = ref(false)
|
||||
@@ -23,7 +26,17 @@ 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)
|
||||
@@ -61,22 +74,6 @@ 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'
|
||||
@@ -85,7 +82,7 @@ async function request(path: string, options: RequestInit = {}) {
|
||||
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))
|
||||
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()
|
||||
@@ -96,6 +93,7 @@ async function safe(work: () => Promise<void>) {
|
||||
}
|
||||
|
||||
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'))
|
||||
@@ -143,7 +141,7 @@ function cancelHabitReorder() {
|
||||
}
|
||||
|
||||
function startHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
if (busy.value || isInteractiveTarget(event.target)) return
|
||||
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 }
|
||||
@@ -151,7 +149,7 @@ function startHabitSwipe(h: Habit, event: TouchEvent) {
|
||||
}
|
||||
}
|
||||
function startHabitPointer(h: Habit, event: PointerEvent) {
|
||||
if (busy.value || isInteractiveTarget(event.target)) return
|
||||
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 }
|
||||
}
|
||||
@@ -216,6 +214,7 @@ function setLocalHabitValue(h: Habit, next: number | boolean) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -279,6 +278,7 @@ function cancelHabitSwipe(h?: Habit) {
|
||||
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
|
||||
@@ -293,25 +293,94 @@ async function toggleHabitFromButton(h: Habit) {
|
||||
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 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 openHabitComposer(origin?: { x: number; y: number }) {
|
||||
if (origin) habitComposeOrigin.value = origin
|
||||
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 closeHabitComposer() { habitComposerOpen.value = false }
|
||||
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
|
||||
@@ -329,18 +398,26 @@ async function archiveHabit(h: Habit) {
|
||||
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||||
selectedHabit.value = null
|
||||
await loadHabits()
|
||||
await loadArchivedHabits()
|
||||
emit('notice', '习惯已归档')
|
||||
})
|
||||
}
|
||||
async function deleteHabit(h: Habit) {
|
||||
if (!confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
||||
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
||||
await safe(async () => {
|
||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||
selectedHabit.value = null
|
||||
await loadHabits()
|
||||
await loadArchivedHabits()
|
||||
emit('notice', '习惯已永久删除')
|
||||
})
|
||||
}
|
||||
async function loadArchivedHabits() {
|
||||
archivedHabits.value = await request('/habits?archived=true') as Habit[]
|
||||
}
|
||||
async function toggleArchivedHabits() {
|
||||
showArchivedHabits.value = !showArchivedHabits.value
|
||||
if (showArchivedHabits.value) await safe(loadArchivedHabits)
|
||||
}
|
||||
function refreshHabitDay() {
|
||||
const next = dateKey(new Date())
|
||||
if (next !== todayKey.value) {
|
||||
@@ -430,6 +507,7 @@ onMounted(() => {
|
||||
if (props.view === 'habits' || props.view === 'today-habits') {
|
||||
refreshHabitDay()
|
||||
void loadHabits()
|
||||
void loadArchivedHabits()
|
||||
dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
|
||||
} else {
|
||||
void loadSettings()
|
||||
@@ -447,7 +525,7 @@ onBeforeUnmount(() => {
|
||||
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||||
<template v-if="view === 'habits' || view === 'today-habits'">
|
||||
<header v-if="view === 'habits'" class="view-intro">
|
||||
<div><small>把想坚持的事,变成每天的日常</small><h2>习惯</h2></div>
|
||||
<div><small>把想坚持的事,变成每天的日常</small></div>
|
||||
<div class="habit-toolbar"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
|
||||
</header>
|
||||
|
||||
@@ -455,9 +533,10 @@ onBeforeUnmount(() => {
|
||||
<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 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" :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>
|
||||
<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>
|
||||
@@ -468,11 +547,18 @@ onBeforeUnmount(() => {
|
||||
<!-- 完整习惯列表 -->
|
||||
<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="addHabit" @keydown.esc="closeHabitComposer">
|
||||
<header class="app-sheet__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>
|
||||
<div class="app-sheet__body"><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></div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="!habitName.trim()">添加习惯</button></footer>
|
||||
<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>
|
||||
@@ -482,9 +568,10 @@ onBeforeUnmount(() => {
|
||||
<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="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>
|
||||
<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="最近一周打卡">
|
||||
@@ -495,13 +582,19 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯,从一件容易坚持的小事开始。</div>
|
||||
<button class="archived-toggle" type="button" @click="toggleArchivedHabits"><ArchiveRestore/>已归档({{ 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="!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>{{ habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></div>
|
||||
<footer class="app-sheet__danger"><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
||||
<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>
|
||||
@@ -510,13 +603,13 @@ onBeforeUnmount(() => {
|
||||
<!-- 设置与数据 -->
|
||||
<template v-else>
|
||||
<header class="view-intro">
|
||||
<div><small>备份、迁移与安全</small><h2>设置与数据</h2></div>
|
||||
<div><small>备份、迁移与安全</small></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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user