diff --git a/backend/models.py b/backend/models.py index 10bce63..94af965 100644 --- a/backend/models.py +++ b/backend/models.py @@ -239,6 +239,21 @@ class Attachment(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) +class Memo(Base): + __tablename__ = "memos" + __table_args__ = ( + Index("ix_memos_user_deleted_updated", "user_id", "deleted_at", "updated_at", "id"), + ) + id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text, default="") + version: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) + + class AuditLog(Base): __tablename__ = "audit_logs" id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) diff --git a/backend/mvp.py b/backend/mvp.py index 12bfd0a..3ad4847 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -32,6 +32,7 @@ from .models import ( Habit, HabitLog, HabitPause, + Memo, RecurrenceException, RecurrenceTemplate, Task, @@ -48,6 +49,172 @@ def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details)) +class MemoCreate(BaseModel): + title: str = Field(min_length=1, max_length=200) + content: str = "" + + @field_validator("title", mode="before") + @classmethod + def clean_title(cls, value: str) -> str: + return value.strip() + + +class MemoUpdate(BaseModel): + title: str | None = Field(default=None, min_length=1, max_length=200) + content: str | None = None + version: int = Field(ge=1) + + @field_validator("title", mode="before") + @classmethod + def clean_title(cls, value: str | None) -> str | None: + if value is None: + return value + return value.strip() + + @model_validator(mode="after") + def reject_nulls(self): + for field in ("title", "content"): + if field in self.model_fields_set and getattr(self, field) is None: + raise ValueError(f"{field} cannot be null") + return self + + +class MemoOut(BaseModel): + model_config = {"from_attributes": True} + id: UUID + title: str + content: str + version: int + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + + +class MemoListItem(BaseModel): + id: UUID + title: str + excerpt: str + version: int + created_at: datetime + updated_at: datetime + deleted_at: datetime | None + + +def _memo_excerpt(content: str) -> str: + collapsed = " ".join(content.split()) + return collapsed if len(collapsed) <= 120 else collapsed[:119] + "…" + + +@router.get("/memos") +async def list_memos( + scope: str = Query("active", pattern="^(active|trash)$"), + q: str = "", + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=100), + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + filters = [Memo.user_id == user.id] + filters.append(Memo.deleted_at.is_(None) if scope == "active" else Memo.deleted_at.is_not(None)) + query = q.strip() + if query: + pattern = f"%{query}%" + filters.append((Memo.title.ilike(pattern)) | (Memo.content.ilike(pattern))) + total = await db.scalar(select(func.count()).select_from(Memo).where(*filters)) + rows = (await db.scalars( + select(Memo).where(*filters).order_by(Memo.updated_at.desc(), Memo.id.desc()) + .offset((page - 1) * page_size).limit(page_size) + )).all() + items = [MemoListItem( + id=row.id, title=row.title, excerpt=_memo_excerpt(row.content), version=row.version, + created_at=row.created_at, updated_at=row.updated_at, deleted_at=row.deleted_at, + ) for row in rows] + return {"items": items, "total": total or 0, "page": page, "page_size": page_size} + + +@router.post("/memos", response_model=MemoOut, status_code=201) +async def create_memo(payload: MemoCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + row = Memo(user_id=user.id, title=payload.title, content=payload.content) + db.add(row) + await db.flush() + audit(db, user.id, "create", "memo", row.id, fields=["title"]) + await db.commit() + await db.refresh(row) + return row + + +@router.patch("/memos/{memo_id}", response_model=MemoOut) +async def update_memo( + memo_id: UUID, payload: MemoUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db) +): + existing = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id)) + if not existing: + raise HTTPException(404, "备忘录不存在") + if existing.deleted_at is not None: + raise HTTPException(409, "已删除的备忘录不能编辑") + values = payload.model_dump(exclude={"version"}, exclude_unset=True) + now = utcnow() + result = await db.execute( + update(Memo).where( + Memo.id == memo_id, Memo.user_id == user.id, Memo.deleted_at.is_(None), Memo.version == payload.version + ).values(**values, version=Memo.version + 1, updated_at=now) + ) + if result.rowcount != 1: + raise HTTPException(409, "备忘录版本冲突") + audit(db, user.id, "update", "memo", memo_id, fields=sorted(values)) + await db.commit() + return await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id)) + + +@router.post("/memos/{memo_id}/restore", response_model=MemoOut) +async def restore_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id)) + if not row: + raise HTTPException(404, "备忘录不存在") + if row.deleted_at is not None: + row.deleted_at = None + row.updated_at = utcnow() + audit(db, user.id, "restore", "memo", row.id) + await db.commit() + await db.refresh(row) + return row + + +@router.delete("/memos/{memo_id}/purge", status_code=204) +async def purge_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id)) + if not row: + raise HTTPException(404, "备忘录不存在") + if row.deleted_at is None: + raise HTTPException(409, "只能永久删除回收站中的备忘录") + audit(db, user.id, "purge", "memo", row.id) + await db.delete(row) + await db.commit() + return Response(status_code=204) + + +@router.delete("/memos/{memo_id}", status_code=204) +async def delete_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id)) + if not row: + raise HTTPException(404, "备忘录不存在") + if row.deleted_at is None: + now = utcnow() + row.deleted_at = now + row.updated_at = now + audit(db, user.id, "delete", "memo", row.id) + await db.commit() + return Response(status_code=204) + + +@router.get("/memos/{memo_id}", response_model=MemoOut) +async def get_memo(memo_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + row = await db.scalar(select(Memo).where(Memo.id == memo_id, Memo.user_id == user.id)) + if not row: + raise HTTPException(404, "备忘录不存在") + return row + + class RecurrenceCreate(BaseModel): task_id: UUID rrule: str | None = Field(default=None, min_length=5, max_length=1000) @@ -1101,7 +1268,7 @@ def _serialize_export_value(value): return value -def _export_payload(folders, lists, tasks, recurrences, habits, countdowns): +def _export_payload(folders, lists, tasks, recurrences, habits, countdowns, memos): def serialize(row, fields): return {field: _serialize_export_value(getattr(row, field)) for field in fields} @@ -1114,6 +1281,7 @@ def _export_payload(folders, lists, tasks, recurrences, habits, countdowns): "recurrences": [serialize(x, ["id", "task_id", "rrule", "starts_at", "ends_at", "trigger_mode", "after_completion_days", "last_completed_at"]) for x in recurrences], "habits": [serialize(x, ["id", "name", "kind", "target", "max_value", "schedule_type", "weekdays", "month_days", "interval_days", "start_date", "archived_at", "position"]) for x in habits], "countdowns": [serialize(x, ["id", "title", "event_date", "calendar_mode", "lunar_month", "lunar_day", "ignore_year", "kind", "repeat_rule", "icon", "pinned", "archived_at", "created_at", "updated_at"]) for x in countdowns], + "memos": [serialize(x, ["id", "title", "content", "version", "created_at", "updated_at", "deleted_at"]) for x in memos], } @@ -1124,7 +1292,8 @@ async def _load_export_rows(user, db): habits = list((await db.scalars(select(Habit).where(Habit.user_id == user.id))).all()) countdowns = list((await db.scalars(select(Countdown).where(Countdown.user_id == user.id))).all()) recurrences = list((await db.scalars(select(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id))).all()) - return folders, lists, tasks, recurrences, habits, countdowns + memos = list((await db.scalars(select(Memo).where(Memo.user_id == user.id))).all()) + return folders, lists, tasks, recurrences, habits, countdowns, memos @router.get("/export") @@ -1138,7 +1307,7 @@ async def export_csv(user: User = Depends(current_user), db: AsyncSession = Depe output = io.StringIO() writer = csv.writer(output, lineterminator="\n") writer.writerow(["entity", "data"]) - for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns"): + for entity in ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos"): for row in payload[entity]: writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))]) content = "\ufeff" + output.getvalue() @@ -1165,6 +1334,7 @@ async def restore_csv( "recurrences": [], "habits": [], "countdowns": [], + "memos": [], } try: for row in csv.DictReader(io.StringIO(text)): @@ -1246,6 +1416,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg raise HTTPException(422, "不支持的备份版本") parsed_countdowns = _validate_countdown_backups(payload) if mode == "replace": + await db.execute(delete(Memo).where(Memo.user_id == user.id)) await db.execute(delete(Countdown).where(Countdown.user_id == user.id)) await db.execute(delete(Task).where(Task.user_id == user.id)) await db.execute(delete(Habit).where(Habit.user_id == user.id)) @@ -1372,6 +1543,35 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg existing_countdown_ids.add(row_id) occupied_countdown_ids[row_id] = user.id restored += 1 + existing_memo_ids = set((await db.scalars(select(Memo.id).where(Memo.user_id == user.id))).all()) + for raw in payload.get("memos", []): + try: + source_id = UUID(raw["id"]) + title = raw["title"].strip() + content = raw.get("content", "") + version = raw.get("version", 1) + if not title or len(title) > 200 or not isinstance(content, str): + raise ValueError + if not isinstance(version, int) or isinstance(version, bool) or version < 1: + raise ValueError + created_at = datetime.fromisoformat(raw["created_at"]) + updated_at = datetime.fromisoformat(raw["updated_at"]) + deleted_at = datetime.fromisoformat(raw["deleted_at"]) if raw.get("deleted_at") else None + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(422, "无效的备忘录备份数据") from exc + occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == source_id)) + row_id = source_id + while occupied_user not in (None, user.id): + row_id = uuid5(user.id, str(row_id)) + occupied_user = await db.scalar(select(Memo.user_id).where(Memo.id == row_id)) + if mode == "merge" and row_id in existing_memo_ids: + continue + db.add(Memo( + id=row_id, user_id=user.id, title=title, content=content, version=version, + created_at=created_at, updated_at=updated_at, deleted_at=deleted_at, + )) + existing_memo_ids.add(row_id) + restored += 1 audit(db, user.id, "restore", "backup", count=restored, mode=mode) await db.commit() return {"restored": restored, "mode": mode} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8100b30..0db6287 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder, Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search, - Settings, Trash2, X, Repeat2, RefreshCw, + Settings, Trash2, X, Repeat2, RefreshCw, StickyNote, } from 'lucide-vue-next' import { applyMarkdownFormat, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils' import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' @@ -15,6 +15,7 @@ import { nextDialogFocusIndex } from './lib/list-purge' import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' +import MemoPanel from './MemoPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue' import CalendarPicker from './components/CalendarPicker.vue' @@ -26,7 +27,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] } type RepeatOption = TaskRepeatOption type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null } -type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings' +type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings' const initialized = ref(null) const authReady = ref(false) @@ -152,6 +153,8 @@ let recurrenceLoadToken = 0 let todaySummaryLoadToken = 0 const habitComposer = ref | null>(null) const countdownComposer = ref | null>(null) +const memoPanel = ref | null>(null) +const memoTrash = ref(false) const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) let suppressTaskClickId = '' @@ -199,6 +202,7 @@ function activateFloatingAdd(origin: { x: number; y: number }) { composeOrigin.value = origin if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin) else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin) + else if (activeView.value === 'memos') void memoPanel.value?.createMemo() else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose() } async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) { @@ -316,6 +320,7 @@ const activeName = computed(() => { if (activeView.value === 'upcoming') return '最近 7 天' if (activeView.value === 'habits') return '习惯' if (activeView.value === 'countdowns') return '倒数日' + if (activeView.value === 'memos') return '备忘录' if (activeView.value === 'settings') return '设置与数据' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' }) @@ -335,7 +340,7 @@ const visibleTasks = computed(() => { const now = new Date() const end = new Date(now); end.setDate(end.getDate() + 7) let result = sourceTasks.value - if (['habits','settings','countdowns'].includes(activeView.value)) return [] + if (['habits','settings','countdowns','memos'].includes(activeView.value)) return [] if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString()) if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end) return query.value.trim() ? filterTasks(result, query.value) : result @@ -374,7 +379,9 @@ async function api(path: string, options: RequestInit = {}) { if (!response.ok) { let message = '请求失败' try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ } - throw new Error(message) + const requestError = new Error(message) as Error & { status: number } + requestError.status = response.status + throw requestError } return response.status === 204 ? null : response.json() } @@ -596,6 +603,7 @@ async function loadTrash() { }) } async function switchView(view: View, listId?: string) { + if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return taskMutationNavigation.value += 1 activeView.value = view if (!query.value) mobileSearchOpen.value = false @@ -1336,6 +1344,7 @@ onUnmounted(() => { +
我的清单
@@ -1413,6 +1422,7 @@ onUnmounted(() => { +