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
+117 -12
View File
@@ -579,18 +579,74 @@ async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user)
class HabitCreate(BaseModel): class HabitCreate(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
kind: str = Field("boolean", pattern="^(boolean|numeric)$") kind: str = Field("boolean", pattern="^(boolean|numeric)$")
target: float = Field(1, gt=0) target: float = Field(1, gt=0, allow_inf_nan=False)
max_value: float | None = Field(None, gt=0) max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$") schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$")
weekdays: list[int] | None = None weekdays: list[int] | None = None
month_days: list[int] | None = None month_days: list[int] | None = None
interval_days: int | None = Field(None, ge=1) interval_days: int | None = Field(None, ge=1)
start_date: date = Field(default_factory=date.today) start_date: date = Field(default_factory=date.today)
@field_validator("name")
@classmethod
def clean_name(cls, value):
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after") @model_validator(mode="after")
def schedule_valid(self): def values_valid(self):
if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required") if self.schedule_type == "weekly":
if self.kind == "boolean": self.target = 1; self.max_value = 1 if not self.weekdays or len(self.weekdays) != len(set(self.weekdays)) or any(day < 0 or day > 6 for day in self.weekdays):
raise ValueError("weekdays must be non-empty, unique, and between 0 and 6")
elif self.schedule_type == "monthly":
if not self.month_days or len(self.month_days) != len(set(self.month_days)) or any(day < 1 or day > 31 for day in self.month_days):
raise ValueError("month_days must be non-empty, unique, and between 1 and 31")
elif self.schedule_type == "interval" and self.interval_days is None:
raise ValueError("interval_days required")
if self.schedule_type != "weekly":
self.weekdays = None
if self.schedule_type != "monthly":
self.month_days = None
if self.schedule_type != "interval":
self.interval_days = None
if self.kind == "boolean":
self.target = 1
self.max_value = 1
elif self.max_value is not None and self.max_value < self.target:
raise ValueError("max_value must be greater than or equal to target")
return self
class HabitUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
kind: str | None = Field(None, pattern="^(boolean|numeric)$")
target: float | None = Field(None, gt=0, allow_inf_nan=False)
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
schedule_type: str | None = Field(None, pattern="^(daily|weekly|monthly|interval)$")
weekdays: list[int] | None = None
month_days: list[int] | None = None
interval_days: int | None = Field(None, ge=1)
start_date: date | None = None
@field_validator("name")
@classmethod
def clean_name(cls, value):
if value is None:
return value
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
@model_validator(mode="after")
def reject_nulls(self):
for field in self.model_fields_set:
if getattr(self, field) is None:
raise ValueError(f"{field} cannot be null")
return self return self
@@ -606,10 +662,10 @@ class HabitReorder(BaseModel):
class HabitLogInput(BaseModel): class HabitLogInput(BaseModel):
day: date day: date
value: float = Field(gt=0) value: float = Field(gt=0, allow_inf_nan=False)
class HabitLogEdit(BaseModel): value: float = Field(ge=0) class HabitLogEdit(BaseModel): value: float = Field(ge=0, allow_inf_nan=False)
class PauseInput(BaseModel): class PauseInput(BaseModel):
start_date: date start_date: date
end_date: date end_date: date
@@ -629,6 +685,23 @@ async def owned_habit(db, user_id, habit_id):
return row return row
def require_active_habit(habit):
if habit.archived_at is not None:
raise HTTPException(409, "归档习惯不可修改")
async def require_positive_log_day(db, habit, day):
if not scheduled(habit, day):
raise HTTPException(409, "非计划日不可记录正向进度")
paused = await db.scalar(select(HabitPause.id).where(
HabitPause.habit_id == habit.id,
HabitPause.start_date <= day,
HabitPause.end_date >= day,
))
if paused is not None:
raise HTTPException(409, "暂停日不可记录正向进度")
@router.post("/habits", status_code=201) @router.post("/habits", status_code=201)
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id)) max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id))
@@ -662,11 +735,23 @@ async def reorder_habits(payload: HabitReorder, user: User = Depends(current_use
@router.patch("/habits/{habit_id}") @router.patch("/habits/{habit_id}")
async def edit_habit(habit_id: UUID, payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def edit_habit(habit_id: UUID, payload: HabitUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_habit(db, user.id, habit_id) row = await owned_habit(db, user.id, habit_id)
for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value) require_active_habit(row)
row.weekdays = ",".join(map(str, payload.weekdays)) if payload.weekdays else None; row.month_days = ",".join(map(str, payload.month_days)) if payload.month_days else None current = habit_dict(row)
await db.commit(); return habit_dict(row) merged = {key: current[key] for key in HabitCreate.model_fields}
merged.update(payload.model_dump(exclude_unset=True))
try:
validated = HabitCreate.model_validate(merged)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
values = validated.model_dump(exclude={"weekdays", "month_days"})
for key, value in values.items():
setattr(row, key, value)
row.weekdays = ",".join(map(str, validated.weekdays)) if validated.weekdays else None
row.month_days = ",".join(map(str, validated.month_days)) if validated.month_days else None
await db.commit()
return habit_dict(row)
@router.delete("/habits/{habit_id}", status_code=204) @router.delete("/habits/{habit_id}", status_code=204)
@@ -677,6 +762,8 @@ async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db:
@router.delete("/habits/{habit_id}/permanent", status_code=204) @router.delete("/habits/{habit_id}/permanent", status_code=204)
async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
row = await owned_habit(db, user.id, habit_id) row = await owned_habit(db, user.id, habit_id)
if row.archived_at is None:
raise HTTPException(409, "请先归档再永久删除")
await db.delete(row) await db.delete(row)
await db.commit() await db.commit()
return Response(status_code=204) return Response(status_code=204)
@@ -685,6 +772,8 @@ async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_
@router.post("/habits/{habit_id}/logs") @router.post("/habits/{habit_id}/logs")
async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id) habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
await require_positive_log_day(db, habit, payload.day)
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day)) row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day))
value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf")) value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf"))
if habit.kind == "boolean": value = 1 if habit.kind == "boolean": value = 1
@@ -696,13 +785,27 @@ async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Dep
@router.put("/habits/{habit_id}/logs/{day}") @router.put("/habits/{habit_id}/logs/{day}")
async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id) habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day)) row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
if value > (row.value if row else 0):
await require_positive_log_day(db, habit, day)
if row: row.value = value; row.updated_at = utcnow() if row: row.value = value; row.updated_at = utcnow()
else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row) else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row)
await db.commit(); return {"day": row.day, "value": row.value} await db.commit(); return {"day": row.day, "value": row.value}
@router.delete("/habits/{habit_id}/logs/{day}", status_code=204)
async def delete_habit_log(habit_id: UUID, day: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
if row is not None:
await db.delete(row)
await db.commit()
return Response(status_code=204)
@router.get("/habits/{habit_id}/logs") @router.get("/habits/{habit_id}/logs")
async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None, alias="from"), to_date: date | None = Query(default=None, alias="to"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None, alias="from"), to_date: date | None = Query(default=None, alias="to"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id) habit = await owned_habit(db, user.id, habit_id)
@@ -716,7 +819,9 @@ async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None
@router.post("/habits/{habit_id}/pauses", status_code=201) @router.post("/habits/{habit_id}/pauses", status_code=201)
async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
habit = await owned_habit(db, user.id, habit_id); row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()} habit = await owned_habit(db, user.id, habit_id)
require_active_habit(habit)
row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
def scheduled(h, day): def scheduled(h, day):
+28 -3
View File
@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class InitializeRequest(BaseModel): class InitializeRequest(BaseModel):
@@ -45,6 +45,14 @@ class SessionOut(BaseModel):
class NameUpdate(BaseModel): class NameUpdate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
@field_validator("name")
@classmethod
def clean_name(cls, value):
value = value.strip()
if not value:
raise ValueError("name cannot be blank")
return value
class FolderCreate(NameUpdate): class FolderCreate(NameUpdate):
pass pass
@@ -56,8 +64,7 @@ class FolderOut(BaseModel):
name: str name: str
class ListCreate(BaseModel): class ListCreate(NameUpdate):
name: str = Field(min_length=1, max_length=120)
folder_id: UUID | None = None folder_id: UUID | None = None
@@ -80,6 +87,14 @@ class TaskCreate(BaseModel):
parent_id: UUID | None = None parent_id: UUID | None = None
rrule: str | None = Field(default=None, min_length=5, max_length=1000) rrule: str | None = Field(default=None, min_length=5, max_length=1000)
@field_validator("title")
@classmethod
def clean_title(cls, value):
value = value.strip()
if not value:
raise ValueError("title cannot be blank")
return value
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=500) title: str | None = Field(default=None, min_length=1, max_length=500)
@@ -91,6 +106,16 @@ class TaskUpdate(BaseModel):
list_id: UUID | None = None list_id: UUID | None = None
version: int = Field(ge=1) version: int = Field(ge=1)
@field_validator("title")
@classmethod
def clean_title(cls, value):
if value is None:
return value
value = value.strip()
if not value:
raise ValueError("title cannot be blank")
return value
@model_validator(mode="after") @model_validator(mode="after")
def reject_null_non_nullable_fields(self): def reject_null_non_nullable_fields(self):
for field in ("title", "description", "priority", "completed", "list_id", "due_has_time"): for field in ("title", "description", "priority", "completed", "list_id", "due_has_time"):
+32 -7
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2, Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' 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 { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion' import { createCompletionPulse } from './lib/completion-motion'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
@@ -73,6 +73,7 @@ const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(
const taskReorderTarget = ref('') const taskReorderTarget = ref('')
const taskComposeOpen = ref(false) const taskComposeOpen = ref(false)
const composeTitle = ref('') const composeTitle = ref('')
const composeTitleError = ref('')
const composeListId = ref('') const composeListId = ref('')
const composeDueAt = ref('') const composeDueAt = ref('')
const composeHasTime = ref(false) const composeHasTime = ref(false)
@@ -99,6 +100,7 @@ const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.va
function openTaskCompose() { function openTaskCompose() {
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
composeTitle.value = '' composeTitle.value = ''
composeTitleError.value = ''
composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId
composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : '' composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''
composeHasTime.value = false composeHasTime.value = false
@@ -183,8 +185,15 @@ async function updateSelectedTaskRepeat() {
} }
async function submitTaskCompose() { async function submitTaskCompose() {
const taskTitle = composeTitle.value.trim() const normalized = normalizeRequiredName(composeTitle.value)
if (!taskTitle || !composeListId.value) return if (normalized.error) {
composeTitleError.value = normalized.error
return
}
const taskTitle = normalized.value
composeTitle.value = taskTitle
composeTitleError.value = ''
if (!composeListId.value) return
try { try {
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value) const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : '' 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 modalTitle = ref('')
const modalLabel = ref('') const modalLabel = ref('')
const modalValue = ref('') const modalValue = ref('')
const modalError = ref('')
const modalConfirmText = ref('确定') const modalConfirmText = ref('确定')
const modalResolve = ref<((value: string | null) => void) | null>(null) const modalResolve = ref<((value: string | null) => void) | null>(null)
function askText(title: string, label = '', initial = '', confirmText = '确定') { function askText(title: string, label = '', initial = '', confirmText = '确定') {
@@ -226,6 +236,7 @@ function askText(title: string, label = '', initial = '', confirmText = '确定'
modalTitle.value = title modalTitle.value = title
modalLabel.value = label modalLabel.value = label
modalValue.value = initial modalValue.value = initial
modalError.value = ''
modalConfirmText.value = confirmText modalConfirmText.value = confirmText
modalVisible.value = true modalVisible.value = true
modalResolve.value = resolve modalResolve.value = resolve
@@ -236,6 +247,14 @@ function closeModal() {
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null } if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
} }
function confirmModal() { 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 modalVisible.value = false
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null } if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
} }
@@ -300,7 +319,7 @@ async function api(path: string, options: RequestInit = {}) {
}) })
if (!response.ok) { if (!response.ok) {
let message = '请求失败' 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) throw new Error(message)
} }
return response.status === 204 ? null : response.json() return response.status === 204 ? null : response.json()
@@ -670,7 +689,13 @@ function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
selectTask(task) selectTask(task)
} }
async function saveTask() { 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 { try {
const task = selectedTask.value const task = selectedTask.value
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at)) 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"> <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> <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"> <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-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-actions" aria-label="截止时间">
<div class="task-compose-date-control"> <div class="task-compose-date-control">
@@ -931,7 +956,7 @@ onMounted(bootstrap)
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal"> <div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
<div class="modal-box" role="dialog" aria-modal="true"> <div class="modal-box" role="dialog" aria-modal="true">
<h3>{{ modalTitle }}</h3> <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 class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
</div> </div>
</div> </div>
+144 -51
View File
@@ -1,13 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' 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 { 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 { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion' import { createCompletionPulse } from './lib/completion-motion'
type View = 'habits' | 'today-habits' | 'settings' 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 } type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View }>() const props = defineProps<{ view: View }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,6 +17,8 @@ const emit = defineEmits<{
summary: [value: { total: number; completed: number }] summary: [value: { total: number; completed: number }]
}>() }>()
const habits = ref<Habit[]>([]) const habits = ref<Habit[]>([])
const archivedHabits = ref<Habit[]>([])
const showArchivedHabits = ref(false)
const sessions = ref<Session[]>([]) const sessions = ref<Session[]>([])
const audit = ref<any[]>([]) const audit = ref<any[]>([])
const busy = ref(false) const busy = ref(false)
@@ -23,7 +26,17 @@ const error = ref('')
const habitName = ref('') const habitName = ref('')
const habitType = ref<'boolean' | 'numeric'>('boolean') const habitType = ref<'boolean' | 'numeric'>('boolean')
const habitTarget = ref(1) 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 habitComposerOpen = ref(false)
const editingHabit = ref<Habit | null>(null)
const originalHabitForm = ref<HabitFormValues | null>(null)
const selectedHabit = ref<Habit | null>(null) const selectedHabit = ref<Habit | null>(null)
const habitDetailClickSuppressed = ref(false) const habitDetailClickSuppressed = ref(false)
const habitDetailSheet = ref<HTMLElement | null>(null) 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)) watch(hideCompletedHabits, (value) => writeStoredBoolean(window.localStorage, HIDE_COMPLETED_HABITS_STORAGE_KEY, value))
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined 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 = {}) { async function request(path: string, options: RequestInit = {}) {
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) } const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json' 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 }) const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({})) 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') || '' const type = response.headers.get('content-type') || ''
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob() 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 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 isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
function isInteractiveTarget(target: EventTarget | null) { function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label')) 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) { 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] const touch = event.touches[0]
if (touch) { if (touch) {
habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY } 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) { 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 if (event.pointerType === 'touch') return
habitPointerStart.value = { id: h.id, x: event.clientX, y: event.clientY } 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) { async function applyHabitSwipe(h: Habit, deltaX: number) {
if (!habitAction(h).writable) return
const current = logFor(h, todayKey.value)?.value const current = logFor(h, todayKey.value)?.value
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1) const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
const next = deltaX > 0 const next = deltaX > 0
@@ -279,6 +278,7 @@ function cancelHabitSwipe(h?: Habit) {
if (h) habitSwipeOffsets.value[h.id] = 0 if (h) habitSwipeOffsets.value[h.id] = 0
} }
async function toggleHabitFromButton(h: Habit) { async function toggleHabitFromButton(h: Habit) {
if (!habitAction(h).writable) return
const current = logFor(h, todayKey.value)?.value const current = logFor(h, todayKey.value)?.value
const wasDone = isHabitComplete(h.kind, current, h.target ?? 1) const wasDone = isHabitComplete(h.kind, current, h.target ?? 1)
const previous = current ?? 0 const previous = current ?? 0
@@ -293,25 +293,94 @@ async function toggleHabitFromButton(h: Habit) {
error.value = e instanceof Error ? e.message : '请求失败' error.value = e instanceof Error ? e.message : '请求失败'
} }
} }
async function addHabit() { function currentHabitForm(): HabitFormValues {
if (!habitName.value.trim()) return return {
await safe(async () => { name: habitName.value,
await request('/habits', { method: 'POST', body: JSON.stringify({ name: habitName.value.trim(), kind: habitType.value, target: habitTarget.value, schedule_type: 'daily' }) }) kind: habitType.value,
habitName.value = '' target: habitType.value === 'numeric' ? Number(habitTarget.value) : 1,
habitComposerOpen.value = false max_value: habitType.value === 'numeric' ? habitMax.value : 1,
await loadHabits() schedule_type: habitSchedule.value,
emit('notice', '习惯已创建') 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 }) { function refreshHabitErrors() {
if (origin) habitComposeOrigin.value = origin 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 = '' habitName.value = ''
habitType.value = 'boolean' habitType.value = 'boolean'
habitTarget.value = 1 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 habitComposerOpen.value = true
void nextTick(() => habitNameInput.value?.focus()) 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) { function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
if (habitDetailClickSuppressed.value) return if (habitDetailClickSuppressed.value) return
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
@@ -329,18 +398,26 @@ async function archiveHabit(h: Habit) {
await request(`/habits/${h.id}`, { method: 'DELETE' }) await request(`/habits/${h.id}`, { method: 'DELETE' })
selectedHabit.value = null selectedHabit.value = null
await loadHabits() await loadHabits()
await loadArchivedHabits()
emit('notice', '习惯已归档') emit('notice', '习惯已归档')
}) })
} }
async function deleteHabit(h: Habit) { async function deleteHabit(h: Habit) {
if (!confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
await safe(async () => { await safe(async () => {
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' }) await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
selectedHabit.value = null selectedHabit.value = null
await loadHabits() await loadArchivedHabits()
emit('notice', '习惯已永久删除') 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() { function refreshHabitDay() {
const next = dateKey(new Date()) const next = dateKey(new Date())
if (next !== todayKey.value) { if (next !== todayKey.value) {
@@ -430,6 +507,7 @@ onMounted(() => {
if (props.view === 'habits' || props.view === 'today-habits') { if (props.view === 'habits' || props.view === 'today-habits') {
refreshHabitDay() refreshHabitDay()
void loadHabits() void loadHabits()
void loadArchivedHabits()
dayRolloverTimer = setInterval(refreshHabitDay, 60_000) dayRolloverTimer = setInterval(refreshHabitDay, 60_000)
} else { } else {
void loadSettings() void loadSettings()
@@ -447,7 +525,7 @@ onBeforeUnmount(() => {
<!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 --> <!-- 习惯TickTick 风格一次只操作一个习惯不再逐格小按钮误触 -->
<template v-if="view === 'habits' || view === 'today-habits'"> <template v-if="view === 'habits' || view === 'today-habits'">
<header v-if="view === 'habits'" class="view-intro"> <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> <div class="habit-toolbar"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
</header> </header>
@@ -455,9 +533,10 @@ onBeforeUnmount(() => {
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list"> <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> <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)"> <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"> <div class="habit-main">
<span class="habit-name">{{ h.name }}</span> <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> <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> <progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
</div> </div>
@@ -468,11 +547,18 @@ onBeforeUnmount(() => {
<!-- 完整习惯列表 --> <!-- 完整习惯列表 -->
<Transition name="task-compose"> <Transition name="task-compose">
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer"> <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"> <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>NEW HABIT</small><h2 id="habit-compose-title">添加习惯</h2></div><button class="icon" type="button" aria-label="关闭添加习惯" @click="closeHabitComposer"><X /></button></header> <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"><label>习惯名称<input ref="habitNameInput" v-model="habitName" placeholder="例如:每天喝水 8 杯" aria-label="新习惯名称"></label> <div class="app-sheet__body">
<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> <p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="!habitName.trim()">添加习惯</button></footer> <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> </form>
</div> </div>
</Transition> </Transition>
@@ -482,9 +568,10 @@ onBeforeUnmount(() => {
<div v-if="view === 'habits'" class="habit-list"> <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)"> <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="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)"> <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> <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> <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> <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="最近一周打卡"> <div class="habit-week" aria-label="最近一周打卡">
@@ -495,13 +582,19 @@ onBeforeUnmount(() => {
</div> </div>
</article> </article>
<div v-if="!habits.length && !busy" class="empty-panel">还没有习惯从一件容易坚持的小事开始</div> <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> </div>
<Transition name="countdown-detail"> <Transition name="countdown-detail">
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail"> <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"> <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> <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> <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 class="app-sheet__danger"><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer> <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> </article>
</div> </div>
</Transition> </Transition>
@@ -510,13 +603,13 @@ onBeforeUnmount(() => {
<!-- 设置与数据 --> <!-- 设置与数据 -->
<template v-else> <template v-else>
<header class="view-intro"> <header class="view-intro">
<div><small>备份迁移与安全</small><h2>设置与数据</h2></div> <div><small>备份迁移与安全</small></div>
</header> </header>
<div class="settings-grid"> <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"><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 /><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 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 /><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 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 /><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 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> </div>
</template> </template>
</section> </section>
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' 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', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { 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 }) 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', () => { it('distinguishes tapping the add button from dragging it', () => {
expect(isFabDrag(3, 4)).toBe(false) expect(isFabDrag(3, 4)).toBe(false)
expect(isFabDrag(8, 0)).toBe(true) expect(isFabDrag(8, 0)).toBe(true)
+78
View File
@@ -171,6 +171,84 @@ export function writeCountdownCache<T>(items: T[], archived: T[]) {
countdownCache = { items, archived } 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 { export function formatApiErrorDetail(detail: unknown): string {
if (typeof detail === 'string') return detail if (typeof detail === 'string') return detail
if (Array.isArray(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} .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} .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} .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: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: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} .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}} @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))') expect(css).toContain('padding-bottom:calc(16px + env(safe-area-inset-bottom))')
}) })
it('keeps destructive actions in a separate bottom danger zone', () => { it('keeps the danger zone reserved for archived habit deletion', () => {
expect(mvpPanel).toContain('class="app-sheet__danger"') expect(mvpPanel).toContain('v-if="selectedHabit.archived_at" class="app-sheet__danger"')
expect(mvpPanel).toMatch(/app-sheet__danger[\s\S]*?deleteHabit\(selectedHabit\)/) expect(mvpPanel).toContain('deleteHabit(selectedHabit)')
expect(css).toContain('.app-sheet__danger{border-top:1px solid #f1d4cd;') 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}') 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).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"') 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('class="habit-detail-sheet app-sheet app-sheet--detail"')
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"') expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"') expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
expect(mvpPanel).toContain('永久删除')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })") 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('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="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('ref="habitDetailSheet"')
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()") 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', () => { 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(mvpPanel.match(/<Check v-if="isDone\(h, todayKey\)"/g)?.length).toBe(2)
expect(css).toContain('.habit-check{') expect(css).toContain('.habit-check{')
expect(mvpPanel).toContain('@pointerdown="startHabitPointer') 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, next)')
expect(toggleBlock).toContain('setLocalHabitValue(h, previous)') expect(toggleBlock).toContain('setLocalHabitValue(h, previous)')
expect(toggleBlock).not.toContain('await loadHabits()') 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', () => { describe('settings data tools', () => {
it('keeps backup export and restore but removes the standalone import tool', () => { 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("fetch('/api/v1/export.csv'")
expect(mvpPanel).toContain("'dodo-export.csv'") expect(mvpPanel).toContain("'dodo-export.csv'")
expect(mvpPanel).toContain('导出 CSV') expect(mvpPanel).toContain('导出 CSV')
+233
View File
@@ -0,0 +1,233 @@
from datetime import UTC, date, datetime, timedelta
from tests.test_mvp_backend import boot
def create_habit(client, **overrides):
payload = {"name": "阅读", "kind": "numeric", "target": 2, "max_value": 4, "schedule_type": "daily"}
payload.update(overrides)
return client.post("/api/v1/habits", json=payload)
def test_habit_patch_updates_only_submitted_fields_and_trims_name(client):
boot(client)
habit = create_habit(client).json()
response = client.patch(f"/api/v1/habits/{habit['id']}", json={"name": " 深度阅读 "})
assert response.status_code == 200
updated = response.json()
assert updated["name"] == "深度阅读"
assert updated["kind"] == "numeric"
assert updated["target"] == 2
assert updated["max_value"] == 4
def test_habit_name_rejects_blank_on_create_and_update(client):
boot(client)
assert create_habit(client, name=" ").status_code == 422
habit = create_habit(client).json()
assert client.patch(f"/api/v1/habits/{habit['id']}", json={"name": " \t "}).status_code == 422
def test_habit_schedule_validation_and_irrelevant_fields_are_cleared(client):
boot(client)
invalid_payloads = [
{"schedule_type": "weekly"},
{"schedule_type": "weekly", "weekdays": [1, 1]},
{"schedule_type": "weekly", "weekdays": [-1]},
{"schedule_type": "weekly", "weekdays": [7]},
{"schedule_type": "monthly"},
{"schedule_type": "monthly", "month_days": [1, 1]},
{"schedule_type": "monthly", "month_days": [0]},
{"schedule_type": "monthly", "month_days": [32]},
{"schedule_type": "interval"},
{"schedule_type": "interval", "interval_days": 0},
]
for payload in invalid_payloads:
assert create_habit(client, **payload).status_code == 422, payload
response = create_habit(
client,
schedule_type="weekly",
weekdays=[1, 3],
month_days=[8],
interval_days=2,
)
assert response.status_code == 201
assert response.json()["weekdays"] == [1, 3]
assert response.json()["month_days"] is None
assert response.json()["interval_days"] is None
def test_habit_patch_validates_merged_schedule_and_clears_old_schedule_fields(client):
boot(client)
habit = create_habit(client, schedule_type="weekly", weekdays=[1, 3]).json()
invalid = client.patch(f"/api/v1/habits/{habit['id']}", json={"weekdays": []})
assert invalid.status_code == 422
changed = client.patch(
f"/api/v1/habits/{habit['id']}",
json={"schedule_type": "monthly", "month_days": [10]},
)
assert changed.status_code == 200
assert changed.json()["weekdays"] is None
assert changed.json()["month_days"] == [10]
assert changed.json()["interval_days"] is None
def test_habit_create_rejects_non_finite_numeric_target_and_max_value(client):
boot(client)
for field in ("target", "max_value"):
for value in ("NaN", "Infinity", "-Infinity"):
response = create_habit(client, **{field: value})
assert response.status_code == 422, (field, value, response.text)
def test_habit_update_rejects_non_finite_numeric_target_and_max_value(client):
boot(client)
habit = create_habit(client).json()
for field in ("target", "max_value"):
for value in ("NaN", "Infinity", "-Infinity"):
response = client.patch(f"/api/v1/habits/{habit['id']}", json={field: value})
assert response.status_code == 422, (field, value, response.text)
def test_habit_log_post_rejects_non_finite_value(client):
boot(client)
habit = create_habit(client).json()
day = datetime.now(UTC).date().isoformat()
for value in ("NaN", "Infinity", "-Infinity"):
response = client.post(
f"/api/v1/habits/{habit['id']}/logs",
json={"day": day, "value": value},
)
assert response.status_code == 422, (value, response.text)
def test_habit_log_put_rejects_non_finite_value(client):
boot(client)
habit = create_habit(client).json()
day = datetime.now(UTC).date().isoformat()
for value in ("NaN", "Infinity", "-Infinity"):
response = client.put(
f"/api/v1/habits/{habit['id']}/logs/{day}",
json={"value": value},
)
assert response.status_code == 422, (value, response.text)
def test_habit_numeric_targets_and_boolean_normalization(client):
boot(client)
for payload in (
{"kind": "numeric", "target": 0},
{"kind": "numeric", "max_value": 0},
{"kind": "numeric", "target": 5, "max_value": 4},
):
assert create_habit(client, **payload).status_code == 422, payload
boolean = create_habit(client, kind="boolean", target=9, max_value=12).json()
assert boolean["target"] == 1
assert boolean["max_value"] == 1
updated = client.patch(
f"/api/v1/habits/{boolean['id']}", json={"target": 7, "max_value": 8}
)
assert updated.status_code == 200
assert updated.json()["target"] == 1
assert updated.json()["max_value"] == 1
def test_archived_habit_rejects_edit_logs_pause_and_active_permanent_delete(client):
boot(client)
active = create_habit(client).json()
assert client.delete(f"/api/v1/habits/{active['id']}/permanent").status_code == 409
habit = create_habit(client).json()
hid = habit["id"]
day = datetime.now(UTC).date().isoformat()
assert client.put(f"/api/v1/habits/{hid}/logs/{day}", json={"value": 1}).status_code == 200
assert client.delete(f"/api/v1/habits/{hid}").status_code == 204
assert client.patch(f"/api/v1/habits/{hid}", json={"name": "不可编辑"}).status_code == 409
assert client.post(f"/api/v1/habits/{hid}/logs", json={"day": day, "value": 1}).status_code == 409
assert client.put(f"/api/v1/habits/{hid}/logs/{day}", json={"value": 0}).status_code == 409
assert client.delete(f"/api/v1/habits/{hid}/logs/{day}").status_code == 409
assert client.post(
f"/api/v1/habits/{hid}/pauses", json={"start_date": day, "end_date": day}
).status_code == 409
assert client.delete(f"/api/v1/habits/{hid}/permanent").status_code == 204
def test_paused_or_unscheduled_day_rejects_positive_progress_but_allows_correction(client):
boot(client)
monday = date(2026, 9, 7)
tuesday = monday + timedelta(days=1)
habit = create_habit(
client,
schedule_type="weekly",
weekdays=[monday.weekday()],
start_date=monday.isoformat(),
).json()
hid = habit["id"]
assert client.post(
f"/api/v1/habits/{hid}/logs", json={"day": tuesday.isoformat(), "value": 1}
).status_code == 409
assert client.get(f"/api/v1/habits/{hid}/stats").json()["total"] == 0
assert client.put(
f"/api/v1/habits/{hid}/logs/{monday.isoformat()}", json={"value": 2}
).status_code == 200
assert client.post(
f"/api/v1/habits/{hid}/pauses",
json={"start_date": monday.isoformat(), "end_date": monday.isoformat()},
).status_code == 201
assert client.post(
f"/api/v1/habits/{hid}/logs", json={"day": monday.isoformat(), "value": 1}
).status_code == 409
assert client.put(
f"/api/v1/habits/{hid}/logs/{monday.isoformat()}", json={"value": 3}
).status_code == 409
corrected = client.put(
f"/api/v1/habits/{hid}/logs/{monday.isoformat()}", json={"value": 0}
)
assert corrected.status_code == 200
assert corrected.json()["value"] == 0
assert client.delete(f"/api/v1/habits/{hid}/logs/{monday.isoformat()}").status_code == 204
def test_task_folder_and_list_names_are_trimmed_and_blank_rejected(client):
inbox = boot(client)
assert client.post("/api/v1/folders", json={"name": " "}).status_code == 422
folder_response = client.post("/api/v1/folders", json={"name": " 工作 "})
assert folder_response.status_code == 201
folder = folder_response.json()
assert folder["name"] == "工作"
assert client.patch(f"/api/v1/folders/{folder['id']}", json={"name": " \t "}).status_code == 422
assert client.patch(f"/api/v1/folders/{folder['id']}", json={"name": " 生活 "}).json()["name"] == "生活"
assert client.post("/api/v1/lists", json={"name": " "}).status_code == 422
list_response = client.post("/api/v1/lists", json={"name": " 清单 ", "folder_id": folder["id"]})
assert list_response.status_code == 201
task_list = list_response.json()
assert task_list["name"] == "清单"
assert client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": " "}).status_code == 422
assert client.patch(f"/api/v1/lists/{task_list['id']}", json={"name": " 新清单 "}).json()["name"] == "新清单"
assert client.post("/api/v1/tasks", json={"title": " ", "list_id": inbox["id"]}).status_code == 422
task_response = client.post("/api/v1/tasks", json={"title": " 待办 ", "list_id": inbox["id"]})
assert task_response.status_code == 201
task = task_response.json()
assert task["title"] == "待办"
assert client.patch(
f"/api/v1/tasks/{task['id']}", json={"title": " ", "version": task["version"]}
).status_code == 422
renamed = client.patch(
f"/api/v1/tasks/{task['id']}", json={"title": " 已更新 ", "version": task["version"]}
)
assert renamed.status_code == 200
assert renamed.json()["title"] == "已更新"
+10 -1
View File
@@ -246,7 +246,15 @@ def test_habit_reorder_rejects_foreign_or_missing_ids(client):
def test_habit_logs_support_date_range_filter(client): def test_habit_logs_support_date_range_filter(client):
boot(client) boot(client)
habit = client.post("/api/v1/habits", json={"name": "跑步", "kind": "boolean", "schedule_type": "daily"}).json() habit = client.post(
"/api/v1/habits",
json={
"name": "跑步",
"kind": "boolean",
"schedule_type": "daily",
"start_date": "2026-08-01",
},
).json()
hid = habit["id"] hid = habit["id"]
for day in ("2026-08-01", "2026-08-15", "2026-09-01"): for day in ("2026-08-01", "2026-08-15", "2026-09-01"):
client.post(f"/api/v1/habits/{hid}/logs", json={"day": day, "value": 1}) client.post(f"/api/v1/habits/{hid}/logs", json={"day": day, "value": 1})
@@ -323,6 +331,7 @@ def test_habit_permanent_delete_removes_habit_and_history(client):
today = datetime.now(UTC).date().isoformat() today = datetime.now(UTC).date().isoformat()
assert client.put(f"/api/v1/habits/{habit_id}/logs/{today}", json={"value": 2}).status_code == 200 assert client.put(f"/api/v1/habits/{habit_id}/logs/{today}", json={"value": 2}).status_code == 200
assert client.delete(f"/api/v1/habits/{habit_id}").status_code == 204
assert client.delete(f"/api/v1/habits/{habit_id}/permanent").status_code == 204 assert client.delete(f"/api/v1/habits/{habit_id}/permanent").status_code == 204
assert all(row["id"] != habit_id for row in client.get("/api/v1/habits").json()) assert all(row["id"] != habit_id for row in client.get("/api/v1/habits").json())
assert all(row["id"] != habit_id for row in client.get("/api/v1/habits", params={"archived": True}).json()) assert all(row["id"] != habit_id for row in client.get("/api/v1/habits", params={"archived": True}).json())