diff --git a/backend/mvp.py b/backend/mvp.py index 3fb53a6..fba80fb 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -579,18 +579,74 @@ async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user) class HabitCreate(BaseModel): name: str = Field(min_length=1, max_length=200) kind: str = Field("boolean", pattern="^(boolean|numeric)$") - target: float = Field(1, gt=0) - max_value: float | None = Field(None, gt=0) + target: float = Field(1, gt=0, allow_inf_nan=False) + max_value: float | None = Field(None, gt=0, allow_inf_nan=False) schedule_type: str = Field("daily", 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 = 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") - def schedule_valid(self): - if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required") - if self.kind == "boolean": self.target = 1; self.max_value = 1 + def values_valid(self): + if self.schedule_type == "weekly": + 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 @@ -606,10 +662,10 @@ class HabitReorder(BaseModel): class HabitLogInput(BaseModel): 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): start_date: date end_date: date @@ -629,6 +685,23 @@ async def owned_habit(db, user_id, habit_id): 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) 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)) @@ -662,11 +735,23 @@ async def reorder_habits(payload: HabitReorder, user: User = Depends(current_use @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) - for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value) - 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 - await db.commit(); return habit_dict(row) + require_active_habit(row) + current = 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) @@ -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) 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) + if row.archived_at is None: + raise HTTPException(409, "请先归档再永久删除") await db.delete(row) await db.commit() 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") 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) + 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)) value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf")) 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}") 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) + require_active_habit(habit) 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)) + 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() else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row) 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") 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) @@ -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) 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): diff --git a/backend/schemas.py b/backend/schemas.py index a8ad84e..4852b71 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,7 +1,7 @@ from datetime import datetime 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): @@ -45,6 +45,14 @@ class SessionOut(BaseModel): class NameUpdate(BaseModel): 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): pass @@ -56,8 +64,7 @@ class FolderOut(BaseModel): name: str -class ListCreate(BaseModel): - name: str = Field(min_length=1, max_length=120) +class ListCreate(NameUpdate): folder_id: UUID | None = None @@ -80,6 +87,14 @@ class TaskCreate(BaseModel): parent_id: UUID | None = None 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): title: str | None = Field(default=None, min_length=1, max_length=500) @@ -91,6 +106,16 @@ class TaskUpdate(BaseModel): list_id: UUID | None = None 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") def reject_null_non_nullable_fields(self): for field in ("title", "description", "priority", "completed", "list_id", "due_has_time"): diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5e98aa9..efbe6cd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,7 +6,7 @@ import { Settings, Trash2, X, Repeat2, } from 'lucide-vue-next' import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils' -import { isTaskView, nextTotalAfterLocalTaskAdd, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' +import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse } from './lib/completion-motion' import MvpPanel from './MvpPanel.vue' @@ -73,6 +73,7 @@ const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>( const taskReorderTarget = ref('') const taskComposeOpen = ref(false) const composeTitle = ref('') +const composeTitleError = ref('') const composeListId = ref('') const composeDueAt = ref('') const composeHasTime = ref(false) @@ -99,6 +100,7 @@ const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.va function openTaskCompose() { const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value composeTitle.value = '' + composeTitleError.value = '' composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : '' composeHasTime.value = false @@ -183,8 +185,15 @@ async function updateSelectedTaskRepeat() { } async function submitTaskCompose() { - const taskTitle = composeTitle.value.trim() - if (!taskTitle || !composeListId.value) return + const normalized = normalizeRequiredName(composeTitle.value) + if (normalized.error) { + composeTitleError.value = normalized.error + return + } + const taskTitle = normalized.value + composeTitle.value = taskTitle + composeTitleError.value = '' + if (!composeListId.value) return try { const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value) const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : '' @@ -219,6 +228,7 @@ const modalVisible = ref(false) const modalTitle = ref('') const modalLabel = ref('') const modalValue = ref('') +const modalError = ref('') const modalConfirmText = ref('确定') const modalResolve = ref<((value: string | null) => void) | null>(null) function askText(title: string, label = '', initial = '', confirmText = '确定') { @@ -226,6 +236,7 @@ function askText(title: string, label = '', initial = '', confirmText = '确定' modalTitle.value = title modalLabel.value = label modalValue.value = initial + modalError.value = '' modalConfirmText.value = confirmText modalVisible.value = true modalResolve.value = resolve @@ -236,6 +247,14 @@ function closeModal() { if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null } } function confirmModal() { + if (modalLabel.value) { + const normalized = normalizeRequiredName(modalValue.value) + if (normalized.error) { + modalError.value = normalized.error + return + } + modalValue.value = normalized.value + } modalVisible.value = false if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null } } @@ -300,7 +319,7 @@ async function api(path: string, options: RequestInit = {}) { }) if (!response.ok) { let message = '请求失败' - try { const body = await response.json(); message = typeof body.detail === 'string' ? body.detail : message } catch { /* noop */ } + try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ } throw new Error(message) } return response.status === 204 ? null : response.json() @@ -670,7 +689,13 @@ function selectTaskUnlessSwiped(task: Task, toggleChildren = false) { selectTask(task) } async function saveTask() { - if (!selectedTask.value?.title.trim()) return + if (!selectedTask.value) return + const normalized = normalizeRequiredName(selectedTask.value.title) + if (normalized.error) { + error.value = normalized.error + return + } + selectedTask.value.title = normalized.value try { const task = selectedTask.value const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at)) @@ -905,7 +930,7 @@ onMounted(bootstrap)