fix: harden habit input and lifecycle
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m21s

This commit is contained in:
2026-09-09 14:08:19 +08:00
parent 489d120159
commit bc4c281658
10 changed files with 735 additions and 83 deletions
+32 -7
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
import { isTaskView, nextTotalAfterLocalTaskAdd, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
import MvpPanel from './MvpPanel.vue'
@@ -73,6 +73,7 @@ const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(
const taskReorderTarget = ref('')
const taskComposeOpen = ref(false)
const composeTitle = ref('')
const composeTitleError = ref('')
const composeListId = ref('')
const composeDueAt = ref('')
const composeHasTime = ref(false)
@@ -99,6 +100,7 @@ const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.va
function openTaskCompose() {
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
composeTitle.value = ''
composeTitleError.value = ''
composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId
composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''
composeHasTime.value = false
@@ -183,8 +185,15 @@ async function updateSelectedTaskRepeat() {
}
async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim()
if (!taskTitle || !composeListId.value) return
const normalized = normalizeRequiredName(composeTitle.value)
if (normalized.error) {
composeTitleError.value = normalized.error
return
}
const taskTitle = normalized.value
composeTitle.value = taskTitle
composeTitleError.value = ''
if (!composeListId.value) return
try {
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
@@ -219,6 +228,7 @@ const modalVisible = ref(false)
const modalTitle = ref('')
const modalLabel = ref('')
const modalValue = ref('')
const modalError = ref('')
const modalConfirmText = ref('确定')
const modalResolve = ref<((value: string | null) => void) | null>(null)
function askText(title: string, label = '', initial = '', confirmText = '确定') {
@@ -226,6 +236,7 @@ function askText(title: string, label = '', initial = '', confirmText = '确定'
modalTitle.value = title
modalLabel.value = label
modalValue.value = initial
modalError.value = ''
modalConfirmText.value = confirmText
modalVisible.value = true
modalResolve.value = resolve
@@ -236,6 +247,14 @@ function closeModal() {
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
}
function confirmModal() {
if (modalLabel.value) {
const normalized = normalizeRequiredName(modalValue.value)
if (normalized.error) {
modalError.value = normalized.error
return
}
modalValue.value = normalized.value
}
modalVisible.value = false
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
}
@@ -300,7 +319,7 @@ async function api(path: string, options: RequestInit = {}) {
})
if (!response.ok) {
let message = '请求失败'
try { const body = await response.json(); message = typeof body.detail === 'string' ? body.detail : message } catch { /* noop */ }
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
throw new Error(message)
}
return response.status === 204 ? null : response.json()
@@ -670,7 +689,13 @@ function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
selectTask(task)
}
async function saveTask() {
if (!selectedTask.value?.title.trim()) return
if (!selectedTask.value) return
const normalized = normalizeRequiredName(selectedTask.value.title)
if (normalized.error) {
error.value = normalized.error
return
}
selectedTask.value.title = normalized.value
try {
const task = selectedTask.value
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
@@ -905,7 +930,7 @@ onMounted(bootstrap)
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
<header class="app-sheet__header"><div><small>NEW TASK</small><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
<div class="app-sheet__body">
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></label>
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3"></option></select></label></div>
<div class="task-compose-date-actions" aria-label="截止时间">
<div class="task-compose-date-control">
@@ -931,7 +956,7 @@ onMounted(bootstrap)
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
<div class="modal-box" role="dialog" aria-modal="true">
<h3>{{ modalTitle }}</h3>
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus @keyup.enter="confirmModal"></label>
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus :aria-invalid="Boolean(modalError)" aria-describedby="modal-name-error" @input="modalError=''" @keyup.enter="confirmModal"><small v-if="modalError" id="modal-name-error" role="alert" class="field-error">{{ modalError }}</small></label>
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
</div>
</div>
+144 -51
View File
@@ -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>
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatHabitApiError, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -142,6 +142,35 @@ describe('MVP view utilities', () => {
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
})
it('validates safe habit input and emits only changed patch fields', () => {
expect(normalizeRequiredName(' 喝水 ')).toEqual({ value: '喝水', error: '' })
expect(normalizeRequiredName(' \n ')).toEqual({ value: '', error: '名称不能为空,请输入至少一个可见字符。' })
expect(validateHabitForm({ name: ' ', kind: 'numeric', target: 0, max_value: 0, schedule_type: 'daily' })).toMatchObject({ name: '请输入习惯名称', target: '目标值必须大于 0' })
expect(validateHabitForm({ name: '跑步', kind: 'numeric', target: 3, max_value: 2, schedule_type: 'daily' })).toMatchObject({ max_value: '最大值不能小于目标值' })
expect(validateHabitForm({ name: '跑步', kind: 'boolean', target: 1, max_value: 1, schedule_type: 'weekly', weekdays: [] })).toMatchObject({ weekdays: '至少选择一个星期' })
expect(validateHabitForm({ name: '跑步', kind: 'boolean', target: 1, max_value: 1, schedule_type: 'monthly', month_days: [1, 1] })).toMatchObject({ month_days: '日期不能重复' })
expect(validateHabitForm({ name: '跑步', kind: 'boolean', target: 1, max_value: 1, schedule_type: 'interval', interval_days: 0 })).toMatchObject({ interval_days: '间隔天数至少为 1' })
expect(changedHabitFields(
{ name: '跑步', kind: 'numeric', target: 3, max_value: 5, schedule_type: 'weekly', weekdays: [0, 2] },
{ name: '跑步', kind: 'numeric', target: 4, max_value: 5, schedule_type: 'weekly', weekdays: [0, 2] },
)).toEqual({ target: 4 })
})
it('maps habit conflicts and validation arrays to actionable Chinese errors', () => {
expect(formatHabitApiError('暂停日不可记录正向进度')).toBe('今天已暂停,不能记录进度。')
expect(formatHabitApiError('非计划日不可记录正向进度')).toBe('今天未安排该习惯,不能记录进度。')
expect(formatHabitApiError([{ loc: ['body', 'max_value'], msg: 'Input should be greater than 0' }])).toBe('最大值必须大于 0。')
expect(formatHabitApiError([{ loc: ['body', 'weekdays'], msg: 'bad' }])).toBe('每周计划至少选择一天,且不能重复。')
expect(formatHabitApiError({ detail: '请先归档再永久删除' })).toBe('请先归档该习惯,再永久删除。')
})
it('blocks writes on paused, unscheduled, and archived habit rows', () => {
expect(habitActionState({ scheduled: true, paused: false })).toEqual({ writable: true, reason: '' })
expect(habitActionState({ scheduled: true, paused: true })).toEqual({ writable: false, reason: '今天已暂停' })
expect(habitActionState({ scheduled: false, paused: false })).toEqual({ writable: false, reason: '今天未安排' })
expect(habitActionState({ scheduled: true, paused: false }, true)).toEqual({ writable: false, reason: '该习惯已归档' })
})
it('distinguishes tapping the add button from dragging it', () => {
expect(isFabDrag(3, 4)).toBe(false)
expect(isFabDrag(8, 0)).toBe(true)
+78
View File
@@ -171,6 +171,84 @@ export function writeCountdownCache<T>(items: T[], archived: T[]) {
countdownCache = { items, archived }
}
export type HabitFormValues = {
name: string
kind: 'boolean' | 'numeric'
target: number
max_value?: number | null
schedule_type: 'daily' | 'weekly' | 'monthly' | 'interval'
weekdays?: number[] | null
month_days?: number[] | null
interval_days?: number | null
}
export type HabitFormErrors = Partial<Record<keyof HabitFormValues, string>>
export function normalizeRequiredName(input: string) {
const value = input.trim()
return { value, error: value ? '' : '名称不能为空,请输入至少一个可见字符。' }
}
export function validateHabitForm(form: HabitFormValues): HabitFormErrors {
const errors: HabitFormErrors = {}
if (!form.name.trim()) errors.name = '请输入习惯名称'
if (form.kind === 'numeric') {
if (!Number.isFinite(Number(form.target)) || Number(form.target) <= 0) errors.target = '目标值必须大于 0'
if (form.max_value != null && (!Number.isFinite(Number(form.max_value)) || Number(form.max_value) <= 0 || Number(form.max_value) < Number(form.target))) errors.max_value = '最大值不能小于目标值'
}
if (form.schedule_type === 'weekly') {
const values = form.weekdays ?? []
if (!values.length) errors.weekdays = '至少选择一个星期'
else if (new Set(values).size !== values.length) errors.weekdays = '星期不能重复'
}
if (form.schedule_type === 'monthly') {
const values = form.month_days ?? []
if (!values.length || values.some((day) => !Number.isInteger(day) || day < 1 || day > 31)) errors.month_days = '请输入 1 到 31 的日期'
else if (new Set(values).size !== values.length) errors.month_days = '日期不能重复'
}
if (form.schedule_type === 'interval' && (!Number.isInteger(Number(form.interval_days)) || Number(form.interval_days) < 1)) errors.interval_days = '间隔天数至少为 1'
return errors
}
export function changedHabitFields(before: HabitFormValues, after: HabitFormValues) {
const result: Partial<HabitFormValues> = {}
for (const key of Object.keys(after) as Array<keyof HabitFormValues>) {
const oldValue = before[key]
const newValue = after[key]
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) (result as Record<string, unknown>)[key] = newValue
}
return result
}
export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean }, archived = false) {
if (archived) return { writable: false, reason: '该习惯已归档' }
if (cell?.paused) return { writable: false, reason: '今天已暂停' }
if (!cell?.scheduled) return { writable: false, reason: '今天未安排' }
return { writable: true, reason: '' }
}
export function formatHabitApiError(detail: unknown): string {
const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail
? (detail as Record<string, unknown>).detail
: detail
const text = typeof source === 'string' ? source : ''
const mappings: Array<[string, string]> = [
['暂停日不可记录正向进度', '今天已暂停,不能记录进度。'],
['非计划日不可记录正向进度', '今天未安排该习惯,不能记录进度。'],
['归档习惯不可修改', '该习惯已归档,不能继续操作。'],
['请先归档再永久删除', '请先归档该习惯,再永久删除。'],
]
for (const [needle, message] of mappings) if (text.includes(needle)) return message
if (Array.isArray(source)) {
const field = source.map((item) => item && typeof item === 'object' && Array.isArray((item as Record<string, unknown>).loc) ? ((item as Record<string, unknown>).loc as unknown[]).at(-1) : '').find(Boolean)
const fieldMessages: Record<string, string> = {
name: '名称不能为空,请输入至少一个可见字符。', weekdays: '每周计划至少选择一天,且不能重复。', month_days: '每月日期必须是 1–31,且不能重复。', interval_days: '间隔天数至少为 1。', target: '目标值必须大于 0。', max_value: '最大值必须大于 0。',
}
if (typeof field === 'string' && fieldMessages[field]) return fieldMessages[field]
}
return formatApiErrorDetail(source)
}
export function formatApiErrorDetail(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
+3 -1
View File
@@ -28,9 +28,11 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.numeric-action{display:flex;gap:6px;align-items:center}.numeric-action input{width:74px;border:1px solid var(--line);border-radius:8px;padding:7px;background:#fff}.numeric-action .soft-button{min-height:44px;padding:9px 12px}
.habit-row>.icon.ghost{width:44px;height:44px;flex:0 0 44px}.habit-check{margin-left:-4px}.habit-row.done .habit-check .task-check-mark{background:#71856b;border-color:#71856b;color:#fff}.habit-toolbar{display:flex;align-items:center;gap:12px;justify-content:flex-end;color:var(--muted);font-size:12px}.habit-toolbar label{display:inline-flex;align-items:center;gap:6px}.habit-toolbar input{margin:0}.habit-toolbar-today{justify-content:flex-start;margin:0 0 2px 2px}.habit-detail-mask{position:fixed;z-index:85;inset:0;background:rgba(45,38,31,.36);display:grid;place-items:end center;padding:20px}.habit-detail-sheet{width:min(500px,100%);background:#fffdf8;border:1px solid var(--line);border-radius:20px;box-shadow:0 12px 36px rgba(56,40,24,.18);padding:18px;display:grid;gap:16px}.habit-detail-sheet header{display:flex;align-items:center;justify-content:space-between;gap:10px}.habit-detail-sheet header small{color:var(--muted);font-size:11px}.habit-detail-sheet header h3{margin:2px 0 0;font-size:19px}.habit-detail-sheet header button{width:44px;height:44px;border:0;background:transparent;display:grid;place-items:center}.habit-detail-progress{display:flex;justify-content:space-between;align-items:center;padding:14px 0;border-block:1px solid var(--line);font-size:12px;color:var(--muted)}.habit-detail-progress strong{font-size:14px;color:#3c372f}.habit-detail-sheet footer{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}.habit-detail-sheet .habit-delete-button{font-weight:750}
.empty-panel{text-align:center;color:var(--muted);display:grid;place-items:center;gap:10px}.today-empty-panel{min-height:130px}.empty-action{margin-top:4px;color:#655d52}.empty-action svg{width:15px;height:15px}
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
.field-error{display:block;color:var(--danger);font-size:12px;line-height:1.45;overflow-wrap:anywhere}.habit-state-note{color:var(--muted);font-size:11px}.habit-weekdays{min-width:0;border:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}.habit-weekdays legend{width:100%;font-size:12px;font-weight:650}.habit-weekdays label{min-height:44px;display:flex;align-items:center;gap:4px}.habit-compose-sheet input,.habit-compose-sheet select{max-width:100%}.task-check:disabled{cursor:not-allowed;opacity:.55}
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>h2{margin:0;font-size:1.17em}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.drag-handle{width:36px;flex-basis:36px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-mask{padding:0}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab svg{width:25px;height:25px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active{transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36)}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
+60 -7
View File
@@ -97,9 +97,9 @@ describe('mobile sheet contract', () => {
expect(css).toContain('padding-bottom:calc(16px + env(safe-area-inset-bottom))')
})
it('keeps destructive actions in a separate bottom danger zone', () => {
expect(mvpPanel).toContain('class="app-sheet__danger"')
expect(mvpPanel).toMatch(/app-sheet__danger[\s\S]*?deleteHabit\(selectedHabit\)/)
it('keeps the danger zone reserved for archived habit deletion', () => {
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"')
expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;')
})
})
@@ -117,19 +117,35 @@ describe('mobile list row language', () => {
expect(css).toContain('.habit-progress{font-size:16px}')
})
it('keeps destructive habit actions in a detail sheet instead of the list row', () => {
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
expect(mvpPanel).toContain('永久删除')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
expect(mvpPanel).toContain('v-if="!selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
})
it('provides a minimal archived-habit viewing path', () => {
expect(mvpPanel).toContain("request('/habits?archived=true')")
expect(mvpPanel).toContain('已归档({{ archivedHabits.length }}')
expect(mvpPanel).toContain('v-for="h in archivedHabits"')
})
it('reuses the habit composer for edits and patches only changed fields', () => {
expect(mvpPanel).toContain('const editingHabit = ref<Habit | null>(null)')
expect(mvpPanel).toContain('changedHabitFields(originalHabitForm.value, normalized)')
expect(mvpPanel).toContain("method: 'PATCH'")
expect(mvpPanel).toContain("habitComposerTitle")
expect(mvpPanel).toContain("habitFormError.value = reason instanceof Error")
})
})
describe('task and habit row decoration', () => {
@@ -228,7 +244,7 @@ describe('task and habit row decoration', () => {
expect(mvpPanel.match(/<Check v-if="isDone\(h, todayKey\)"/g)?.length).toBe(2)
expect(css).toContain('.habit-check{')
expect(mvpPanel).toContain('@pointerdown="startHabitPointer')
const toggleBlock = mvpPanel.slice(mvpPanel.indexOf('async function toggleHabitFromButton'), mvpPanel.indexOf('async function addHabit'))
const toggleBlock = mvpPanel.slice(mvpPanel.indexOf('async function toggleHabitFromButton'), mvpPanel.indexOf('function currentHabitForm'))
expect(toggleBlock).toContain('setLocalHabitValue(h, next)')
expect(toggleBlock).toContain('setLocalHabitValue(h, previous)')
expect(toggleBlock).not.toContain('await loadHabits()')
@@ -395,9 +411,46 @@ describe('mobile touch targets', () => {
})
})
describe('approved habit safety and U2 title hierarchy', () => {
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain('<h2>修改密码</h2>')
expect(mvpPanel).toContain('<h2>登录会话</h2>')
expect(mvpPanel).toContain('<h2>最近活动</h2>')
expect(css).toContain('.tool-card>h2{')
})
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
expect(app).toContain('const modalError = ref')
expect(app).toContain('role="alert" class="field-error"')
expect(app).toContain('normalizeRequiredName')
expect(mvpPanel).toContain('habitErrors.name')
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
expect(mvpPanel).toContain('const habitFormInvalid = computed')
expect(mvpPanel).toContain(':disabled="busy || habitFormInvalid"')
expect(mvpPanel).toContain('请修正表单中的错误后再保存')
})
it('keeps the 390px habit sheets full width with 44px close and bottom actions', () => {
expect(css).toContain('@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
expect(css).toContain('.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
expect(css).toContain('.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}')
})
it('disables illegal habit writes and keeps optimistic rollback', () => {
expect(mvpPanel).toContain('habitAction(h)')
expect(mvpPanel).toContain(':disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain(':aria-disabled="!habitAction(h).writable"')
expect(mvpPanel).toContain('setLocalHabitValue(h, previous)')
expect(mvpPanel).toContain('formatHabitApiError')
})
})
describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => {
expect(mvpPanel).toContain('<h3>数据导出与恢复</h3>')
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
expect(mvpPanel).toContain("'dodo-export.csv'")
expect(mvpPanel).toContain('导出 CSV')