feat: permanently delete archived lists
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-09 21:34:49 +08:00
parent 0e291ddb9c
commit d49c81212d
9 changed files with 957 additions and 6 deletions
+66 -2
View File
@@ -10,6 +10,7 @@ import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalize
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { nextDialogFocusIndex } from './lib/list-purge'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
@@ -30,6 +31,12 @@ const password = ref('')
const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([])
const purgeListTarget = ref<TaskList | null>(null)
const purgeListSubmitting = ref(false)
const purgeListError = ref('')
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
const purgeListDialog = ref<HTMLElement | null>(null)
let purgeListTrigger: HTMLElement | null = null
const tasks = ref<Task[]>([])
const overdueTasks = ref<Task[]>([])
const trash = ref<Task[]>([])
@@ -779,7 +786,19 @@ async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
if (kind === 'lists') {
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
if (answer === null) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadArchivedLists(); await refreshAll(); toast('清单已归档') } catch (reason) { fail(reason) }
try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/${kind}/${item.id}`, { method: 'DELETE' })
await loadArchivedLists()
await refreshAll()
if (wasCurrentList) {
selectedTask.value = null
mobileDetail.value = false
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
await switchView('tasks', inboxId)
}
toast('清单已归档')
} catch (reason) { fail(reason) }
return
}
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
@@ -792,6 +811,43 @@ async function loadArchivedLists() {
async function restoreList(item: TaskList) {
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
}
function openPurgeList(item: TaskList, trigger?: EventTarget | null) {
purgeListTarget.value = item
purgeListError.value = ''
purgeListTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement | null
nextTick(() => purgeCancelButton.value?.focus())
}
function closePurgeList() {
if (purgeListSubmitting.value) return
purgeListTarget.value = null
purgeListError.value = ''
nextTick(() => purgeListTrigger?.focus())
}
function handlePurgeDialogKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
if (event.key !== 'Tab' || !purgeListDialog.value) return
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
if (!controls.length) return
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
}
async function confirmPurgeList() {
if (!purgeListTarget.value || purgeListSubmitting.value) return
purgeListSubmitting.value = true
purgeListError.value = ''
try {
const purgedId = purgeListTarget.value.id
await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })
archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)
purgeListTarget.value = null
toast('清单已永久删除')
} catch (reason) {
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
} finally {
purgeListSubmitting.value = false
}
}
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
@@ -984,7 +1040,7 @@ onMounted(bootstrap)
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<template v-if="archivedLists.length">
<div class="section-title"><span>已归档清单</span></div>
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div>
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button><button class="danger-text" aria-label="永久删除清单" @click="openPurgeList(list,$event.currentTarget)"><Trash2/>永久删除</button></span></div>
</template>
</div>
<nav class="sidebar-management" aria-label="管理">
@@ -1098,6 +1154,14 @@ onMounted(bootstrap)
</Transition>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
<h3 id="purge-list-title">永久删除清单{{ purgeListTarget.name }}</h3>
<p id="purge-list-description">将永久删除其中的全部任务子任务重复规则附件及实体文件此操作无法撤销</p>
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除' : '永久删除' }}</button></div>
</section>
</div>
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
<div class="modal-box" role="dialog" aria-modal="true">
<h3>{{ modalTitle }}</h3>
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { nextDialogFocusIndex } from './list-purge'
describe('archived list purge dialog behavior', () => {
it('wraps Tab focus between the cancel and destructive actions', () => {
expect(nextDialogFocusIndex(0, 2, true)).toBe(1)
expect(nextDialogFocusIndex(1, 2, false)).toBe(0)
})
it('leaves focus alone while moving between interior controls', () => {
expect(nextDialogFocusIndex(1, 3, true)).toBeNull()
expect(nextDialogFocusIndex(1, 3, false)).toBeNull()
expect(nextDialogFocusIndex(0, 0, false)).toBeNull()
})
})
+6
View File
@@ -0,0 +1,6 @@
export function nextDialogFocusIndex(currentIndex: number, controlCount: number, shiftKey: boolean) {
if (controlCount < 2) return null
if (shiftKey && currentIndex === 0) return controlCount - 1
if (!shiftKey && currentIndex === controlCount - 1) return 0
return null
}
File diff suppressed because one or more lines are too long
+28
View File
@@ -737,4 +737,32 @@ describe('sidebar layout', () => {
it('styles archived rows through the new list-row-main structure', () => {
expect(css).toContain('.archived-row .list-row-main>svg{color:#c9a45c}')
})
it('offers permanent deletion only beside archived lists', () => {
expect(app).toContain('aria-label="永久删除清单" @click="openPurgeList(list,$event.currentTarget)"')
expect(app).toContain("api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })")
expect(app).not.toMatch(/list\.is_inbox[^\n]*openPurgeList/)
})
it('uses a guarded custom confirmation that keeps failures visible', () => {
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
expect(app).toContain('ref="purgeCancelButton"')
expect(app).toContain('purgeCancelButton.value?.focus()')
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
expect(app).toContain('if (purgeListSubmitting.value) return')
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
expect(app).toContain(':disabled="purgeListSubmitting"')
expect(app).toContain('role="alert" class="purge-list-error"')
expect(css).toContain('.purge-list-dialog button{min-height:44px;')
})
it('removes a purged row and persists inbox navigation after archiving the current list', () => {
expect(app).toContain('archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)')
expect(app).toContain("const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id")
expect(app).toContain('if (wasCurrentList)')
expect(app).toContain("await switchView('tasks', inboxId)")
expect(app).toContain('selectedTask.value = null')
expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)")
})
})