feat: add standalone memos
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m38s

This commit is contained in:
2026-09-12 10:22:35 +08:00
parent e07a1de1f5
commit 31c2218edc
18 changed files with 1063 additions and 13 deletions
+15
View File
@@ -239,6 +239,21 @@ class Attachment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 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): class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
+203 -3
View File
@@ -32,6 +32,7 @@ from .models import (
Habit, Habit,
HabitLog, HabitLog,
HabitPause, HabitPause,
Memo,
RecurrenceException, RecurrenceException,
RecurrenceTemplate, RecurrenceTemplate,
Task, 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)) 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): class RecurrenceCreate(BaseModel):
task_id: UUID task_id: UUID
rrule: str | None = Field(default=None, min_length=5, max_length=1000) rrule: str | None = Field(default=None, min_length=5, max_length=1000)
@@ -1101,7 +1268,7 @@ def _serialize_export_value(value):
return 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): def serialize(row, fields):
return {field: _serialize_export_value(getattr(row, field)) for field in 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], "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], "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], "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()) 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()) 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()) 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") @router.get("/export")
@@ -1138,7 +1307,7 @@ async def export_csv(user: User = Depends(current_user), db: AsyncSession = Depe
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output, lineterminator="\n") writer = csv.writer(output, lineterminator="\n")
writer.writerow(["entity", "data"]) 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]: for row in payload[entity]:
writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))]) writer.writerow([entity, json.dumps(row, ensure_ascii=False, separators=(",", ":"))])
content = "\ufeff" + output.getvalue() content = "\ufeff" + output.getvalue()
@@ -1165,6 +1334,7 @@ async def restore_csv(
"recurrences": [], "recurrences": [],
"habits": [], "habits": [],
"countdowns": [], "countdowns": [],
"memos": [],
} }
try: try:
for row in csv.DictReader(io.StringIO(text)): 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, "不支持的备份版本") raise HTTPException(422, "不支持的备份版本")
parsed_countdowns = _validate_countdown_backups(payload) parsed_countdowns = _validate_countdown_backups(payload)
if mode == "replace": 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(Countdown).where(Countdown.user_id == user.id))
await db.execute(delete(Task).where(Task.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)) 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) existing_countdown_ids.add(row_id)
occupied_countdown_ids[row_id] = user.id occupied_countdown_ids[row_id] = user.id
restored += 1 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) audit(db, user.id, "restore", "backup", count=restored, mode=mode)
await db.commit() await db.commit()
return {"restored": restored, "mode": mode} return {"restored": restored, "mode": mode}
+16 -6
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { import {
ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder, ArchiveRestore, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search, 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' } 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 { 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' 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 { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue' import CountdownPanel from './CountdownPanel.vue'
import MemoPanel from './MemoPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue' import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.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 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 RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null } 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<boolean | null>(null) const initialized = ref<boolean | null>(null)
const authReady = ref(false) const authReady = ref(false)
@@ -152,6 +153,8 @@ let recurrenceLoadToken = 0
let todaySummaryLoadToken = 0 let todaySummaryLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null) const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null) const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
const memoTrash = ref(false)
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 }) const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
let suppressTaskClickId = '' let suppressTaskClickId = ''
@@ -199,6 +202,7 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
composeOrigin.value = origin composeOrigin.value = origin
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin) if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(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() else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
} }
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) { 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 === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯' if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日' if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'memos') return '备忘录'
if (activeView.value === 'settings') return '设置与数据' if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱' return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
}) })
@@ -335,7 +340,7 @@ const visibleTasks = computed(() => {
const now = new Date() const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7) const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value 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 === '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) 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 return query.value.trim() ? filterTasks(result, query.value) : result
@@ -374,7 +379,9 @@ async function api(path: string, options: RequestInit = {}) {
if (!response.ok) { if (!response.ok) {
let message = '请求失败' let message = '请求失败'
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ } 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() return response.status === 204 ? null : response.json()
} }
@@ -596,6 +603,7 @@ async function loadTrash() {
}) })
} }
async function switchView(view: View, listId?: string) { async function switchView(view: View, listId?: string) {
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
taskMutationNavigation.value += 1 taskMutationNavigation.value += 1
activeView.value = view activeView.value = view
if (!query.value) mobileSearchOpen.value = false if (!query.value) mobileSearchOpen.value = false
@@ -1336,6 +1344,7 @@ onUnmounted(() => {
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button> <button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button> <button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button> <button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</button>
</nav> </nav>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div> <div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders"> <div class="folders">
@@ -1413,6 +1422,7 @@ onUnmounted(() => {
<template v-if="['habits','settings'].includes(activeView)"> <template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" /> <MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
</template> </template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else> <template v-else>
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度"> <section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
@@ -1468,7 +1478,7 @@ onUnmounted(() => {
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section> <section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small> <small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
<div class="field markdown"> <div class="field markdown">
<div class="field-label"><span>备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div> <div class="field-label"><span>任务备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
<div v-if="!markdownPreview" class="markdown-editor-shell"> <div v-if="!markdownPreview" class="markdown-editor-shell">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式"> <div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button> <button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button>
@@ -1496,7 +1506,7 @@ onUnmounted(() => {
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div> <div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav> <nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" /> <FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="activeView!=='memos' || !memoTrash" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
<Transition name="task-compose"> <Transition name="task-compose">
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose"> <div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
<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">
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const app = readFileSync('src/App.vue', 'utf8')
const css = readFileSync('src/memo.css', 'utf8')
const panel = readFileSync('src/MemoPanel.vue', 'utf8')
const editor = readFileSync('src/components/MemoEditor.vue', 'utf8')
describe('memo shell integration', () => {
it('places Memos immediately after Countdowns in desktop navigation and keeps mobile tabs unchanged', () => {
const nav = app.slice(app.indexOf('<nav class="primary-nav">'), app.indexOf('</nav>', app.indexOf('<nav class="primary-nav">')))
expect(nav.indexOf("switchView('memos')")).toBeGreaterThan(nav.indexOf("switchView('countdowns')"))
expect(nav.match(/switchView\('memos'\)/g)).toHaveLength(1)
const bottom = app.slice(app.indexOf('<nav class="bottom"'), app.indexOf('</nav>', app.indexOf('<nav class="bottom"')))
expect(bottom).not.toContain("switchView('memos')")
expect(bottom.match(/aria-current=/g)).toHaveLength(4)
})
it('routes the shared cat FAB to memo creation and hides it in memo trash', () => {
expect(app).toContain("activeView.value === 'memos'")
expect(app).toContain('memoPanel.value?.createMemo()')
expect(app).toContain(':show="activeView!==\'memos\' || !memoTrash"')
expect(panel).toContain("scope.value === 'trash'")
})
it('keeps memo UI componentized and task detail copy explicit', () => {
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
expect(panel).toContain("import MemoRow")
expect(panel).toContain("import MemoEditor")
expect(app).toContain('<span>任务详情</span>')
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
})
it('uses a 350px desktop detail, near-full mobile sheet, continuous rows and reduced motion', () => {
expect(css).toContain('.memo-editor{width:350px')
expect(css).toContain('.memo-list{display:grid;gap:0')
expect(css).toContain('.memo-row+.memo-row{border-top:1px solid var(--border-cream)}')
expect(css).toContain('@media(max-width:930px){.memo-panel{')
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto')
expect(css).toContain('height:min(92dvh,820px)')
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
expect(editor).toContain("window.addEventListener('beforeunload'")
expect(editor).toContain("event.key !== 'Tab'")
})
})
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import MemoPanel from './MemoPanel.vue'
const cleanups: Array<() => void> = []
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
type RequestMock = ReturnType<typeof vi.fn<(path: string, options?: RequestInit) => Promise<unknown>>>
async function mount(request: RequestMock) {
const host = document.createElement('div'); document.body.append(host)
let panel: InstanceType<typeof MemoPanel> | null = null
const app = createApp(() => h(MemoPanel, { ref: (value: unknown) => { panel = value as InstanceType<typeof MemoPanel> }, request }))
app.mount(host)
cleanups.push(() => { app.unmount(); host.remove() })
await Promise.resolve(); await Promise.resolve(); await nextTick()
return { host, vm: panel! }
}
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoPanel', () => {
it('loads active memos in server order and appends the next 50', async () => {
const request = vi.fn(async (path: string) => path.includes('page=2')
? { items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 }
: { items: [item], total: 51 })
const { host } = await mount(request)
expect(request.mock.calls[0][0]).toContain('/memos?scope=active')
expect(request.mock.calls[0][0]).toContain('page_size=50')
expect(host.querySelectorAll('.memo-row')).toHaveLength(1)
host.querySelector<HTMLButtonElement>('.memo-load-more')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['第一条', '第二页'])
})
it('searches title and body through the server and has a search empty state', async () => {
vi.useFakeTimers()
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
const { host } = await mount(request)
const input = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
input.value = '咖啡'; input.dispatchEvent(new Event('input'))
await vi.advanceTimersByTimeAsync(260); await nextTick()
expect(request.mock.calls.at(-1)?.[0]).toContain('q=%E5%92%96%E5%95%A1')
expect(host.textContent).toContain('没有匹配的备忘录')
vi.useRealTimers()
})
it('only makes the memo list inert while the detail is a mobile sheet', async () => {
const originalWidth = window.innerWidth
const request = vi.fn(async (path: string) => path.startsWith('/memos/m1') ? { ...item, content: '正文' } : { items: [item], total: 1 })
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1200 })
const desktop = await mount(request)
desktop.host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await Promise.resolve(); await nextTick()
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(false)
expect(desktop.host.querySelector('.memo-editor')?.hasAttribute('aria-modal')).toBe(false)
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
window.dispatchEvent(new Event('resize')); await nextTick()
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
expect(desktop.host.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
})
it('switches between active and trash and creates through the exposed FAB action', async () => {
const request = vi.fn(async (path: string, options?: RequestInit) => options?.method === 'POST'
? { ...item, id: 'new', title: '未命名备忘录', content: '', excerpt: '' }
: { items: [], total: 0 })
const { host, vm } = await mount(request)
host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
expect(request.mock.calls.at(-1)?.[0]).toContain('scope=trash')
host.querySelector<HTMLButtonElement>('[data-scope="active"]')!.click(); await Promise.resolve(); await Promise.resolve(); await nextTick()
await vm.createMemo()
expect(request).toHaveBeenCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) })
expect(host.querySelector('.memo-editor')).not.toBeNull()
})
})
+90
View File
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Archive, FileText, Search } from 'lucide-vue-next'
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type Memo } from './components/MemoEditor.vue'
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ request: RequestFn }>()
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash'] }>()
const scope = ref<'active' | 'trash'>('active')
const query = ref('')
const items = ref<MemoListItem[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const refreshing = ref(false)
const error = ref('')
const selected = ref<Memo | null>(null)
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
const mobileDetail = ref(window.innerWidth <= 930)
let timer: number | undefined
let generation = 0
const hasMore = computed(() => items.value.length < total.value)
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
async function load(reset = true) {
const token = ++generation
if (reset) { page.value = 1; loading.value = true } else refreshing.value = true
error.value = ''
try {
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(page.value), page_size: '50' })
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
if (token !== generation) return
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
finally { if (token === generation) { loading.value = false; refreshing.value = false } }
}
async function loadMore() { if (!hasMore.value || refreshing.value) return; page.value += 1; await load(false) }
async function setScope(next: 'active' | 'trash') {
if (next === scope.value) return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
selected.value = null; scope.value = next; emit('scope', next); await load()
}
async function selectMemo(id: string) {
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
try { selected.value = await props.request(`/memos/${id}`) as Memo } catch (reason) { error.value = reason instanceof Error ? reason.message : '读取失败' }
}
async function createMemo() {
if (scope.value === 'trash') return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
try {
const created = await props.request('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) }) as Memo
items.value = [{ ...created, excerpt: '' }, ...items.value]; total.value += 1; selected.value = created
emit('notice', '备忘录已创建')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '创建失败' }
}
function updateItem(memo: Memo) {
selected.value = memo
const index = items.value.findIndex((item) => item.id === memo.id)
if (index >= 0) items.value[index] = { ...memo, excerpt: memo.content.replace(/\s+/g, ' ').trim().slice(0, 120) }
}
function removeItem(id: string) { items.value = items.value.filter((item) => item.id !== id); total.value = Math.max(0, total.value - 1); selected.value = null }
function removeRestoredItem(memo: Memo) { removeItem(memo.id) }
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
watch(query, () => { if (timer) clearTimeout(timer); timer = window.setTimeout(() => void load(), 250) })
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
onUnmounted(() => { window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
</script>
<template>
<section class="memo-panel" :class="{'memo-panel--detail':selected}">
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
<div class="memo-toolbar">
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
<label class="memo-search"><Search/><input v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
</div>
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录</div>
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角团子猫新建一条'}}</span></div>
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
</div>
<button v-if="hasMore" class="secondary memo-load-more" :disabled="refreshing" @click="loadMore">{{refreshing?'正在加载':'加载更多'}}</button>
</div>
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" @saved="updateItem" @close="selected=null" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
</section>
</template>
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils' import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' }) const props = withDefaults(defineProps<{ label?: string; show?: boolean }>(), { label: '添加', show: true })
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>() const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
const dragging = ref(false) const dragging = ref(false)
const snapping = ref(false) const snapping = ref(false)
@@ -115,7 +115,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<button class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate"> <button v-if="props.show" class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true"> <svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
<g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g> <g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g>
<g class="fab-cat__character"> <g class="fab-cat__character">
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick } from 'vue'
import MemoEditor, { type Memo } from './MemoEditor.vue'
const cleanups: Array<() => void> = []
const memo: Memo = { id: 'm1', title: '旧标题', content: '正文', version: 3, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
async function mount(overrides: Record<string, unknown> = {}) {
const host = document.createElement('div'); document.body.append(host)
const events: Record<string, unknown[]> = { saved: [], close: [] }
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
return { host, request: request as ReturnType<typeof vi.fn>, events }
}
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
describe('MemoEditor', () => {
it('requires a 1-200 character title and saves explicitly with its version', async () => {
const { host, request, events } = await mount()
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = ' 新标题 '; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await nextTick(); await nextTick()
expect(request).toHaveBeenCalledWith('/memos/m1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: '新标题', content: '正文', version: 3 }) }))
expect(events.saved).toHaveLength(1)
})
it('supports Cmd/Ctrl+S and prevents browser save', async () => {
const { host, request } = await mount()
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = '新标题'; title.dispatchEvent(new Event('input'))
const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true })
host.querySelector('.memo-editor')!.dispatchEvent(event); await nextTick(); await nextTick()
expect(event.defaultPrevented).toBe(true)
expect(request).toHaveBeenCalledOnce()
})
it('keeps the draft and offers reload after a 409 conflict', async () => {
const request = vi.fn().mockRejectedValue(Object.assign(new Error('备忘录版本冲突'), { status: 409 }))
const { host } = await mount({ request })
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
title.value = '我的草稿'; title.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
expect(title.value).toBe('我的草稿')
expect(host.textContent).toContain('版本冲突')
expect(host.querySelector('.memo-reload')).not.toBeNull()
})
it('keeps the editor open and reports destructive action failures', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => {
if (options?.method === 'DELETE') throw new Error('删除失败')
return memo
})
const { host, events } = await mount({ request })
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
expect(host.textContent).toContain('删除失败')
expect(events.close).toEqual([])
})
it('traps mobile focus, closes on Escape, and restores opener focus', async () => {
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
const { host, events } = await mount({ mobile: true })
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
expect(dialog.getAttribute('aria-modal')).toBe('true')
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
last.focus()
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
dialog.dispatchEvent(tab)
expect(tab.defaultPrevented).toBe(true)
expect(document.activeElement).toBe(dialog.querySelector('button'))
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
await nextTick()
expect(events.close).toEqual([true])
expect(document.activeElement).toBe(opener)
opener.remove()
})
it('guards dirty close and allows clean close', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { host, events } = await mount()
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
expect(confirm).toHaveBeenCalled()
expect(events.close).toEqual([])
confirm.mockReturnValue(true)
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
expect(events.close).toEqual([true])
})
})
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArchiveRestore, Trash2, X } from 'lucide-vue-next'
export type Memo = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ memo: Memo; request: RequestFn; mobile?: boolean }>()
const emit = defineEmits<{ saved: [memo: Memo]; close: []; deleted: [id: string]; restored: [memo: Memo]; purged: [id: string]; notice: [message: string] }>()
const title = ref('')
const content = ref('')
const saving = ref(false)
const error = ref('')
const conflict = ref(false)
const titleInput = ref<HTMLInputElement | null>(null)
const root = ref<HTMLElement | null>(null)
let initial = { title: '', content: '' }
let opener: HTMLElement | null = null
const dirty = computed(() => title.value !== initial.title || content.value !== initial.content)
function loadDraft(memo: Memo) {
title.value = memo.title; content.value = memo.content
initial = { title: memo.title, content: memo.content }
error.value = ''; conflict.value = false
}
watch(() => props.memo, loadDraft, { immediate: true })
function close() {
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
emit('close')
nextTick(() => opener?.focus())
}
function validate() {
const value = title.value.trim()
if (!value) return '请输入标题'
if (value.length > 200) return '标题最多 200 个字符'
return ''
}
async function save() {
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
saving.value = true; conflict.value = false
try {
const updated = await props.request(`/memos/${props.memo.id}`, { method: 'PATCH', body: JSON.stringify({ title: title.value.trim(), content: content.value, version: props.memo.version }) }) as Memo
loadDraft(updated); emit('saved', updated); emit('notice', '备忘录已保存')
} catch (reason) {
const status = (reason as { status?: number }).status
conflict.value = status === 409
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : '保存失败'
} finally { saving.value = false }
}
async function reload() {
try { const fresh = await props.request(`/memos/${props.memo.id}`) as Memo; loadDraft(fresh); emit('saved', fresh) } catch (reason) { error.value = reason instanceof Error ? reason.message : '重新载入失败' }
}
async function remove() {
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
error.value = ''
try {
await props.request(`/memos/${props.memo.id}`, { method: 'DELETE' })
emit('deleted', props.memo.id); emit('notice', '备忘录已移到回收站')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '删除失败' }
}
async function restore() {
error.value = ''
try {
const restored = await props.request(`/memos/${props.memo.id}/restore`, { method: 'POST' }) as Memo
emit('restored', restored); emit('notice', '备忘录已恢复')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '恢复失败' }
}
async function purge() {
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
error.value = ''
try {
await props.request(`/memos/${props.memo.id}/purge`, { method: 'DELETE' })
emit('purged', props.memo.id); emit('notice', '备忘录已永久删除')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '永久删除失败' }
}
function keydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
if (event.key === 'Escape') { event.preventDefault(); close(); return }
if (!props.mobile || event.key !== 'Tab' || !root.value) return
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
if (!controls.length) return
const first = controls[0], last = controls.at(-1)!
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
onMounted(() => { opener = document.activeElement as HTMLElement; window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
defineExpose({ dirty, requestClose: close })
</script>
<template>
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
<div class="memo-editor__fields">
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
<label>正文<textarea v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…" :disabled="Boolean(memo.deleted_at)"></textarea></label>
<p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p>
</div>
<footer v-if="!memo.deleted_at"><button type="button" class="danger-text" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || !dirty" @click="save">{{ saving ? '正在保存' : '保存' }}</button></footer>
<footer v-else><button type="button" class="secondary" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" @click="purge"><Trash2/>永久删除</button></footer>
</aside>
</template>
+42
View File
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createApp, h, nextTick, ref } from 'vue'
import MemoRow from './MemoRow.vue'
const cleanups: Array<() => void> = []
const memo = { id: 'm1', title: '旅行清单', excerpt: '护照\n 充电器 相机', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
describe('MemoRow', () => {
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
const host = document.createElement('div'); document.body.append(host)
const selected: string[] = []
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string) => selected.push(id) }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
const button = host.querySelector('button')!
expect(button.classList.contains('active')).toBe(true)
expect(button.getAttribute('aria-current')).toBe('true')
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
button.click()
expect(selected).toEqual(['m1'])
})
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
const host = document.createElement('div'); document.body.append(host)
const current = ref({ ...memo, excerpt: '' })
const app = createApp(() => h(MemoRow, { memo: current.value }))
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
const excerpt = () => host.querySelector('.memo-row__excerpt')?.textContent
expect(excerpt()).toBe('暂无正文')
current.value = { ...current.value, excerpt: '保存后\n 新摘要 正常' }
await nextTick()
expect(excerpt()).toBe('保存后 新摘要 正常')
current.value = { ...current.value, excerpt: ' \n\t ' }
await nextTick()
expect(excerpt()).toBe('暂无正文')
})
})
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue'
export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
const emit = defineEmits<{ select: [id: string] }>()
const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim())
const updated = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at))
</script>
<template>
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id)">
<strong>{{ memo.title }}</strong>
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
<time :datetime="memo.updated_at">{{ updated }}</time>
</button>
</template>
+2
View File
@@ -65,6 +65,8 @@ describe('MVP view utilities', () => {
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'tasks', 'list-2') writeStoredNavigation(fakeStorage, 'dodo.navigation', 'tasks', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'tasks', listId: 'list-2' })
writeStoredNavigation(fakeStorage, 'dodo.navigation', 'memos', 'list-2')
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'memos', listId: 'list-2' })
storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' })) storage.set('dodo.navigation', JSON.stringify({ view: 'invalid', listId: 'list-2' }))
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
}) })
+2 -2
View File
@@ -1,7 +1,7 @@
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'> type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings' type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string } type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'settings']) const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation { export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try { try {
+1
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import './style.css' import './style.css'
import './memo.css'
createApp(App).mount('#app') createApp(App).mount('#app')
if ('serviceWorker' in navigator && import.meta.env.PROD) { if ('serviceWorker' in navigator && import.meta.env.PROD) {
+3
View File
@@ -0,0 +1,3 @@
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor>header,.memo-editor>footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor>header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor>header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow:auto;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-editor>footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor>footer button{min-height:44px}.memo-editor-scrim{display:none}
@media(max-width:930px){.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;height:min(92dvh,820px);border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor>footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
+40
View File
@@ -0,0 +1,40 @@
"""add independent memos
Revision ID: 0017_memos
Revises: 0016_recurrence_trigger_modes
"""
import sqlalchemy as sa
from alembic import op
revision = "0017_memos"
down_revision = "0016_recurrence_trigger_modes"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"memos",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("title", sa.String(length=200), nullable=False),
sa.Column("content", sa.Text(), nullable=False, server_default=""),
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.CheckConstraint("version >= 1", name="ck_memos_version_positive"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_memos_user_id", "memos", ["user_id"])
op.create_index(
"ix_memos_user_deleted_updated", "memos", ["user_id", "deleted_at", "updated_at", "id"]
)
def downgrade() -> None:
op.drop_index("ix_memos_user_deleted_updated", table_name="memos")
op.drop_index("ix_memos_user_id", table_name="memos")
op.drop_table("memos")
+281
View File
@@ -0,0 +1,281 @@
from sqlalchemy import select
from backend.auth import hash_password
from backend.db import get_db
from backend.models import Memo, TaskList, User
def boot(client):
response = client.post(
"/api/v1/setup/initialize",
json={"username": "owner", "password": "correct horse battery staple"},
)
assert response.status_code == 201
async def add_user(username: str):
db_gen = get_db()
db = await anext(db_gen)
try:
user = User(username=username, password_hash=hash_password("correct horse battery staple"))
db.add(user)
await db.flush()
db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True))
await db.commit()
finally:
await db_gen.aclose()
def test_create_and_get_memo_trims_title_and_defaults_content(client):
boot(client)
created = client.post("/api/v1/memos", json={"title": " 第一篇 "})
assert created.status_code == 201
item = created.json()
assert item["title"] == "第一篇"
assert item["content"] == ""
assert item["version"] == 1
assert item["deleted_at"] is None
assert item["created_at"]
assert item["updated_at"]
assert client.get(f"/api/v1/memos/{item['id']}").json() == item
def test_memo_title_validation(client):
boot(client)
assert client.post("/api/v1/memos", json={"title": " "}).status_code == 422
assert client.post("/api/v1/memos", json={"title": "x" * 201}).status_code == 422
def test_create_memo_validates_title_length_after_trimming(client):
boot(client)
one = client.post("/api/v1/memos", json={"title": " x "})
two_hundred = client.post("/api/v1/memos", json={"title": f" {'x' * 200} "})
assert one.status_code == 201
assert one.json()["title"] == "x"
assert two_hundred.status_code == 201
assert two_hundred.json()["title"] == "x" * 200
assert client.post("/api/v1/memos", json={"title": " "}).status_code == 422
assert client.post("/api/v1/memos", json={"title": f" {'x' * 201} "}).status_code == 422
def test_update_memo_validates_title_length_after_trimming(client):
boot(client)
memo = client.post("/api/v1/memos", json={"title": "Before"}).json()
one = client.patch(
f"/api/v1/memos/{memo['id']}", json={"title": " x ", "version": memo["version"]}
)
two_hundred = client.patch(
f"/api/v1/memos/{memo['id']}",
json={"title": f" {'x' * 200} ", "version": one.json()["version"]},
)
assert one.status_code == 200
assert one.json()["title"] == "x"
assert two_hundred.status_code == 200
assert two_hundred.json()["title"] == "x" * 200
assert client.patch(
f"/api/v1/memos/{memo['id']}", json={"title": " ", "version": two_hundred.json()["version"]}
).status_code == 422
assert client.patch(
f"/api/v1/memos/{memo['id']}",
json={"title": f" {'x' * 201} ", "version": two_hundred.json()["version"]},
).status_code == 422
def test_list_memos_scopes_searches_orders_paginates_and_builds_excerpt(client):
boot(client)
first = client.post(
"/api/v1/memos", json={"title": "Alpha note", "content": " many\n words\tinside "}
).json()
second = client.post(
"/api/v1/memos", json={"title": "Second", "content": "alpha in body " + "x" * 200}
).json()
third = client.post("/api/v1/memos", json={"title": "Third"}).json()
assert client.delete(f"/api/v1/memos/{third['id']}").status_code == 204
page = client.get("/api/v1/memos", params={"scope": "active", "q": "ALPHA", "page_size": 1})
assert page.status_code == 200
data = page.json()
assert data["total"] == 2
assert data["page"] == 1
assert data["page_size"] == 1
assert data["items"][0]["id"] == second["id"]
assert data["items"][0]["excerpt"] == ("alpha in body " + "x" * 105 + "")
assert "content" not in data["items"][0]
second_page = client.get(
"/api/v1/memos", params={"scope": "active", "q": "alpha", "page": 2, "page_size": 1}
).json()
assert second_page["items"][0]["id"] == first["id"]
assert second_page["items"][0]["excerpt"] == "many words inside"
assert [item["id"] for item in client.get("/api/v1/memos", params={"scope": "trash"}).json()["items"]] == [third["id"]]
assert client.get("/api/v1/memos", params={"page_size": 101}).status_code == 422
def test_patch_uses_atomic_version_lock_and_deleted_memos_reject_edit(client):
boot(client)
memo = client.post("/api/v1/memos", json={"title": "Before", "content": "secret"}).json()
updated = client.patch(
f"/api/v1/memos/{memo['id']}",
json={"title": " After ", "content": "new secret", "version": 1},
)
assert updated.status_code == 200
assert updated.json()["title"] == "After"
assert updated.json()["content"] == "new secret"
assert updated.json()["version"] == 2
assert client.patch(
f"/api/v1/memos/{memo['id']}", json={"title": "stale", "version": 1}
).status_code == 409
assert client.get(f"/api/v1/memos/{memo['id']}").json()["title"] == "After"
assert client.delete(f"/api/v1/memos/{memo['id']}").status_code == 204
assert client.patch(
f"/api/v1/memos/{memo['id']}", json={"content": "forbidden", "version": 2}
).status_code == 409
def test_patch_requires_version(client):
boot(client)
memo = client.post("/api/v1/memos", json={"title": "Versioned"}).json()
assert client.patch(f"/api/v1/memos/{memo['id']}", json={"title": "No version"}).status_code == 422
def test_restore_and_purge_lifecycle(client):
boot(client)
active = client.post("/api/v1/memos", json={"title": "Active"}).json()
assert client.delete(f"/api/v1/memos/{active['id']}/purge").status_code == 409
assert client.delete(f"/api/v1/memos/{active['id']}").status_code == 204
deleted = client.get(f"/api/v1/memos/{active['id']}").json()
restored = client.post(f"/api/v1/memos/{active['id']}/restore")
assert restored.status_code == 200
assert restored.json()["deleted_at"] is None
assert restored.json()["updated_at"] > deleted["updated_at"]
assert client.delete(f"/api/v1/memos/{active['id']}").status_code == 204
assert client.delete(f"/api/v1/memos/{active['id']}/purge").status_code == 204
assert client.get(f"/api/v1/memos/{active['id']}").status_code == 404
def test_cross_user_memo_access_is_uniform_404(client):
boot(client)
memo = client.post("/api/v1/memos", json={"title": "Owner only"}).json()
client.post("/api/v1/auth/logout")
client.portal.call(add_user, "other")
assert client.post(
"/api/v1/auth/login", json={"username": "other", "password": "correct horse battery staple"}
).status_code == 200
assert client.get(f"/api/v1/memos/{memo['id']}").status_code == 404
assert client.patch(f"/api/v1/memos/{memo['id']}", json={"title": "x", "version": 1}).status_code == 404
assert client.delete(f"/api/v1/memos/{memo['id']}").status_code == 404
assert client.post(f"/api/v1/memos/{memo['id']}/restore").status_code == 404
assert client.delete(f"/api/v1/memos/{memo['id']}/purge").status_code == 404
def test_memo_audit_records_actions_without_content(client):
boot(client)
memo = client.post("/api/v1/memos", json={"title": "Audit", "content": "TOP SECRET"}).json()
client.patch(
f"/api/v1/memos/{memo['id']}", json={"content": "NEW SECRET", "version": memo["version"]}
)
client.delete(f"/api/v1/memos/{memo['id']}")
client.post(f"/api/v1/memos/{memo['id']}/restore")
client.delete(f"/api/v1/memos/{memo['id']}")
client.delete(f"/api/v1/memos/{memo['id']}/purge")
logs = [row for row in client.get("/api/v1/audit-logs").json() if row["entity_type"] == "memo"]
assert [row["action"] for row in reversed(logs)] == ["create", "update", "delete", "restore", "delete", "purge"]
serialized = str(logs)
assert "TOP SECRET" not in serialized
assert "NEW SECRET" not in serialized
def test_json_and_csv_backup_round_trip_memos_with_all_fields(client):
boot(client)
active = client.post("/api/v1/memos", json={"title": "Active", "content": "alpha"}).json()
active = client.patch(
f"/api/v1/memos/{active['id']}", json={"title": "Active 2", "version": 1}
).json()
deleted = client.post("/api/v1/memos", json={"title": "Deleted", "content": "trash"}).json()
client.delete(f"/api/v1/memos/{deleted['id']}")
exported = client.get("/api/v1/export").json()
assert len(exported["memos"]) == 2
exported_active = next(row for row in exported["memos"] if row["title"] == "Active 2")
assert exported_active["content"] == "alpha"
assert exported_active["version"] == 2
assert exported_active["created_at"].replace("+00:00", "Z") == active["created_at"]
assert exported_active["updated_at"].replace("+00:00", "Z") == active["updated_at"]
assert exported_active["deleted_at"] is None
csv_backup = client.get("/api/v1/export.csv")
assert b"memos" in csv_backup.content
restored_csv = client.post(
"/api/v1/restore.csv?mode=replace",
files={"file": ("dodo-export.csv", csv_backup.content, "text/csv")},
)
assert restored_csv.status_code == 200
assert client.get("/api/v1/memos").json()["total"] == 1
assert client.get("/api/v1/memos", params={"scope": "trash"}).json()["total"] == 1
restored_active = client.get("/api/v1/memos").json()["items"][0]
detail = client.get(f"/api/v1/memos/{restored_active['id']}").json()
assert detail["title"] == "Active 2"
assert detail["content"] == "alpha"
assert detail["version"] == 2
assert detail["created_at"] == active["created_at"]
assert detail["updated_at"] == active["updated_at"]
client.post("/api/v1/memos", json={"title": "Extra"})
merged = client.post("/api/v1/restore?mode=merge", json=exported)
assert merged.status_code == 200
assert client.get("/api/v1/memos").json()["total"] == 2
def test_restore_reassigns_foreign_memo_to_current_user(client):
boot(client)
exported = client.get("/api/v1/export").json()
client.portal.call(add_user, "other")
async def add_foreign_memo():
db_gen = get_db()
db = await anext(db_gen)
try:
other = await db.scalar(select(User).where(User.username == "other"))
memo = Memo(user_id=other.id, title="Foreign")
db.add(memo)
await db.commit()
return str(memo.id)
finally:
await db_gen.aclose()
foreign_id = client.portal.call(add_foreign_memo)
exported["memos"] = [{
"id": foreign_id,
"title": "Imported",
"content": "owned here",
"version": 3,
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-02T00:00:00+00:00",
"deleted_at": None,
}]
response = client.post("/api/v1/restore?mode=merge", json=exported)
assert response.status_code == 200
imported = client.get("/api/v1/memos", params={"q": "Imported"}).json()["items"][0]
assert imported["id"] != foreign_id
assert client.get(f"/api/v1/memos/{imported['id']}").json()["content"] == "owned here"
assert client.post("/api/v1/restore?mode=merge", json=exported).status_code == 200
assert client.get("/api/v1/memos", params={"q": "Imported"}).json()["total"] == 1
def test_legacy_backup_without_memos_still_restores(client):
boot(client)
backup = client.get("/api/v1/export").json()
backup.pop("memos", None)
assert client.post("/api/v1/restore?mode=replace", json=backup).status_code == 200
+36
View File
@@ -0,0 +1,36 @@
import os
import sqlite3
import subprocess
from pathlib import Path
def run_alembic(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["DODO_DATABASE_URL"] = f"sqlite+aiosqlite:///{database}"
return subprocess.run(
["uv", "run", "alembic", *args], cwd=repo, env=env, text=True, capture_output=True, check=False
)
def test_memos_migration_upgrade_and_downgrade(tmp_path: Path):
repo = Path(__file__).resolve().parents[1]
database = tmp_path / "migration.sqlite3"
assert run_alembic(repo, database, "upgrade", "0016_recurrence_trigger_modes").returncode == 0
upgraded = run_alembic(repo, database, "upgrade", "0017_memos")
assert upgraded.returncode == 0, upgraded.stderr
with sqlite3.connect(database) as connection:
columns = {row[1]: row for row in connection.execute("PRAGMA table_info(memos)")}
assert set(columns) == {"id", "user_id", "title", "content", "version", "created_at", "updated_at", "deleted_at"}
assert columns["title"][3] == 1
assert columns["content"][3] == 1
assert columns["version"][3] == 1
indexes = {row[1] for row in connection.execute("PRAGMA index_list(memos)")}
assert "ix_memos_user_deleted_updated" in indexes
downgraded = run_alembic(repo, database, "downgrade", "0016_recurrence_trigger_modes")
assert downgraded.returncode == 0, downgraded.stderr
with sqlite3.connect(database) as connection:
assert connection.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='memos'"
).fetchone() is None