diff --git a/backend/main.py b/backend/main.py index 2e349d8..dc2e846 100644 --- a/backend/main.py +++ b/backend/main.py @@ -37,7 +37,9 @@ from .schemas import ( FolderOut, InitializeRequest, ListCreate, + ListMove, ListOut, + ListReorder, LoginRequest, NameUpdate, SessionOut, @@ -320,6 +322,12 @@ async def delete_folder( return Response(status_code=204) +async def _lock_list_order(db: AsyncSession, user_id: UUID) -> None: + # Serialize list creation/moves/reorders per user so max-position allocation + # and full-scope reorder validation cannot interleave. + await db.scalar(select(User.id).where(User.id == user_id).with_for_update()) + + @app.post("/api/v1/lists", response_model=ListOut, status_code=201) async def create_list( payload: ListCreate, @@ -334,7 +342,26 @@ async def create_list( ) ): raise HTTPException(status_code=404, detail="文件夹不存在") - item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name) + await _lock_list_order(db, user.id) + folder_scope = ( + TaskList.folder_id == payload.folder_id + if payload.folder_id is not None + else TaskList.folder_id.is_(None) + ) + max_position = await db.scalar( + select(func.max(TaskList.position)).where( + TaskList.user_id == user.id, + folder_scope, + TaskList.is_inbox.is_(False), + TaskList.deleted_at.is_(None), + ) + ) + item = TaskList( + user_id=user.id, + folder_id=payload.folder_id, + name=payload.name, + position=(max_position if max_position is not None else -1) + 1, + ) db.add(item) await db.flush() audit(db, user.id, "create", "list", item.id) @@ -371,6 +398,128 @@ async def _owned_list( return item +@app.put("/api/v1/lists/reorder", status_code=204) +async def reorder_lists( + payload: ListReorder, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + if payload.folder_id is not None: + folder = await db.scalar( + select(Folder).where( + Folder.id == payload.folder_id, + Folder.user_id == user.id, + Folder.deleted_at.is_(None), + ) + ) + if folder is None: + raise HTTPException(status_code=404, detail="文件夹不存在") + await _lock_list_order(db, user.id) + folder_scope = ( + TaskList.folder_id == payload.folder_id + if payload.folder_id is not None + else TaskList.folder_id.is_(None) + ) + scope_rows = list( + ( + await db.scalars( + select(TaskList) + .where( + TaskList.user_id == user.id, + folder_scope, + TaskList.is_inbox.is_(False), + TaskList.deleted_at.is_(None), + ) + .order_by(TaskList.position, TaskList.created_at, TaskList.id) + .with_for_update() + ) + ).all() + ) + if any(list_id not in {row.id for row in scope_rows} for list_id in payload.list_ids): + inbox_requested = await db.scalar( + select(TaskList.id).where( + TaskList.id.in_(payload.list_ids), + TaskList.user_id == user.id, + TaskList.is_inbox.is_(True), + ) + ) + if inbox_requested: + raise HTTPException(status_code=409, detail="系统收集箱不能排序") + raise HTTPException(status_code=409, detail="清单不属于指定作用域") + if set(payload.list_ids) != {row.id for row in scope_rows}: + raise HTTPException(status_code=409, detail="清单顺序已变化,请刷新后重试") + rows_by_id = {row.id: row for row in scope_rows} + for position, list_id in enumerate(payload.list_ids): + rows_by_id[list_id].position = position + await db.commit() + return Response(status_code=204) + + +@app.put("/api/v1/lists/{list_id}/move", response_model=ListOut) +async def move_list_to_folder( + list_id: UUID, + payload: ListMove, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + await _lock_list_order(db, user.id) + item = await _owned_list(db, user.id, list_id) + if item.is_inbox: + raise HTTPException(status_code=409, detail="系统收集箱不能移动") + if payload.folder_id is not None: + folder = await db.scalar( + select(Folder).where( + Folder.id == payload.folder_id, + Folder.user_id == user.id, + Folder.deleted_at.is_(None), + ) + ) + if folder is None: + raise HTTPException(status_code=404, detail="文件夹不存在") + target_scope = ( + TaskList.folder_id == payload.folder_id + if payload.folder_id is not None + else TaskList.folder_id.is_(None) + ) + target_rows = list( + ( + await db.scalars( + select(TaskList) + .where( + TaskList.user_id == user.id, + target_scope, + TaskList.is_inbox.is_(False), + TaskList.deleted_at.is_(None), + TaskList.id != item.id, + ) + .order_by(TaskList.position, TaskList.created_at, TaskList.id) + .with_for_update() + ) + ).all() + ) + expected_ids = {row.id for row in target_rows} | {item.id} + if set(payload.list_ids) != expected_ids: + raise HTTPException(status_code=409, detail="目标清单顺序已变化,请刷新后重试") + + rows_by_id = {row.id: row for row in target_rows} + rows_by_id[item.id] = item + item.folder_id = payload.folder_id + for position, ordered_id in enumerate(payload.list_ids): + rows_by_id[ordered_id].position = position + await db.flush() + audit( + db, + user.id, + "move", + "list", + item.id, + folder_id=str(payload.folder_id) if payload.folder_id else None, + ) + await db.commit() + await db.refresh(item) + return item + + @app.patch("/api/v1/lists/{list_id}", response_model=ListOut) async def rename_list( list_id: UUID, diff --git a/backend/schemas.py b/backend/schemas.py index 4852b71..8014882 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -74,8 +74,30 @@ class ListOut(BaseModel): folder_id: UUID | None name: str is_inbox: bool + position: int +class ListMove(BaseModel): + folder_id: UUID | None + list_ids: list[UUID] = Field(min_length=1) + + @model_validator(mode="after") + def unique_ids(self): + if len(self.list_ids) != len(set(self.list_ids)): + raise ValueError("list_ids must be unique") + return self + + +class ListReorder(BaseModel): + folder_id: UUID | None + list_ids: list[UUID] = Field(min_length=1) + + @model_validator(mode="after") + def unique_ids(self): + if len(self.list_ids) != len(set(self.list_ids)): + raise ValueError("list_ids must be unique") + return self + class TaskCreate(BaseModel): title: str = Field(min_length=1, max_length=500) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9536ba1..03c35ee 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,7 @@ import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, group import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils' import { csrfHeader } from './lib/csrf' import { createCompletionPulse } from './lib/completion-motion' +import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' @@ -39,6 +40,14 @@ const activeView = ref(restoredNavigation.view) const selectedTask = ref(null) const sidebarCreateOpen = ref(false) const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null) +const listMoveMenuOpen = ref(false) +const listDrag = ref<{ id: string; pointerId: number; startX: number; startY: number; offsetY: number; lastX: number; lastY: number } | null>(null) +const listDropFolderId = ref(undefined) +const listReorderTarget = ref('') +const listReorderPlacement = ref<'before' | 'after'>('after') +let listHandleLongPressTimer: number | undefined +let listHandlePending: { id: string; pointer: ListDragPointer } | undefined +let suppressListClickId = '' const query = ref('') const error = ref('') const notice = ref('') @@ -792,8 +801,137 @@ function runSidebarCreate(kind: 'folder' | 'list') { sidebarCreateOpen.value = false kind === 'folder' ? void createFolder() : void createList(null) } -function closeSidebarAction() { sidebarAction.value = null } +function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.value = false } function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next } + +function beginListDrag(list: TaskList, pointer: ListDragPointer) { + if (list.is_inbox) return + clearListHandlePress() + listDrag.value = { id: list.id, pointerId: pointer.pointerId, startX: pointer.clientX, startY: pointer.clientY, offsetY: 0, lastX: pointer.clientX, lastY: pointer.clientY } + listDropFolderId.value = list.folder_id + listReorderTarget.value = list.id + captureListDragPointer(pointer) +} +function startListHandlePress(list: TaskList, event: PointerEvent) { + const pointer = snapshotListDragPointer(event) + if (event.pointerType !== 'touch') { + beginListDrag(list, pointer) + return + } + clearListHandlePress() + listHandlePending = { id: list.id, pointer } + listHandleLongPressTimer = window.setTimeout(() => beginListDrag(list, pointer), 450) +} +function moveListHandle(list: TaskList, event: PointerEvent) { + if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId) { + if (hasExceededLongPressMovement( + { x: listHandlePending.pointer.clientX, y: listHandlePending.pointer.clientY }, + { x: event.clientX, y: event.clientY }, + 5, + )) clearListHandlePress() + return + } + moveListDrag(list, event) +} +function clearListHandlePress() { + if (listHandleLongPressTimer) window.clearTimeout(listHandleLongPressTimer) + listHandleLongPressTimer = undefined + listHandlePending = undefined +} +function resolveListDrop(event: PointerEvent) { + const activeId = listDrag.value?.id + const elements = document.elementsFromPoint(event.clientX, event.clientY) + const row = elements.map((element) => element.closest('[data-list-id]')).find((element) => element?.dataset.listId !== activeId) + if (row?.dataset.listId) { + const target = lists.value.find((item) => item.id === row.dataset.listId) + if (target && !target.is_inbox) { + listDropFolderId.value = target.folder_id + listReorderTarget.value = target.id + const rect = row.getBoundingClientRect() + listReorderPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after' + return + } + } + const folder = elements.map((element) => element.closest('[data-folder-id]')).find(Boolean) + if (folder?.dataset.folderId) { + listDropFolderId.value = folder.dataset.folderId + listReorderTarget.value = '' + expandedFolders.value = new Set(expandedFolders.value).add(folder.dataset.folderId) + return + } + if (elements.some((element) => element.closest('.list-root-drop'))) { + listDropFolderId.value = null + listReorderTarget.value = '' + } +} +function moveListDrag(list: TaskList, event: PointerEvent) { + const drag = listDrag.value + if (!drag || drag.id !== list.id) return + drag.offsetY = event.clientY - drag.startY + drag.lastX = event.clientX + drag.lastY = event.clientY + resolveListDrop(event) +} +async function persistListMove(list: TaskList, folderId: string | null, targetId?: string, placement: 'before' | 'after' = 'after') { + if (list.is_inbox) return + const previous = lists.value + const result = moveListToScope(previous, list.id, folderId, targetId, placement) + if (result.items === previous) return + lists.value = result.items + if (folderId) expandedFolders.value = new Set(expandedFolders.value).add(folderId) + try { + if (list.folder_id !== folderId) { + await api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) }) + } else { + await api('/lists/reorder', { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) }) + } + toast(folderId ? '清单已移动' : '清单已移出文件夹') + } catch (reason) { + lists.value = previous + fail(reason) + } +} +function finishListDrag(list: TaskList, event: PointerEvent) { + clearListHandlePress() + const drag = listDrag.value + if (!drag || drag.id !== list.id) return + const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 5 + const folderId = listDropFolderId.value ?? null + const targetId = listReorderTarget.value && listReorderTarget.value !== list.id ? listReorderTarget.value : undefined + const placement = listReorderPlacement.value + cancelListDrag() + if (!moved && folderId === list.folder_id && !targetId) return + suppressListClickId = list.id + window.setTimeout(() => { if (suppressListClickId === list.id) suppressListClickId = '' }, 400) + void persistListMove(list, folderId, targetId, placement) +} +function selectListUnlessDragged(list: TaskList) { + if (suppressListClickId === list.id) { suppressListClickId = ''; return } + void switchView('tasks', list.id) +} +function cancelListDrag() { + clearListHandlePress() + listDrag.value = null + listDropFolderId.value = undefined + listReorderTarget.value = '' +} +function openListMoveMenu() { listMoveMenuOpen.value = !listMoveMenuOpen.value } +function moveListFromMenu(folderId: string | null) { + const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null + if (!item) return + listMoveMenuOpen.value = false + void persistListMove(item, folderId) + closeSidebarAction() +} +function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') { + return getAdjacentListMove(lists.value, item.id, direction) !== null +} +function moveListWithinScope(item: TaskList, direction: 'up' | 'down') { + const move = getAdjacentListMove(lists.value, item.id, direction) + if (!move) return + void persistListMove(item, item.folder_id, move.targetId, move.placement) + closeSidebarAction() +} function formatDue(value: string | null, hasTime = true) { if (!value) return '' const date = new Date(value) @@ -837,13 +975,13 @@ onMounted(bootstrap) -
我的清单
+
我的清单
-
-
-
+
+
+
-
+