feat: add standalone memos
This commit is contained in:
@@ -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
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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'")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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('暂无正文')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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: '' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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) {
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user