fix: harden habit input and lifecycle
This commit is contained in:
+117
-12
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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"] == "已更新"
|
||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user