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>