From 927e15cf1727bb6cbf2ae9946df4d3b1782470ee Mon Sep 17 00:00:00 2001 From: bboysoul Date: Mon, 21 Sep 2026 09:37:55 +0800 Subject: [PATCH] [verified] harden trash actions and mobile details --- frontend/e2e/trash-grouped-layout.spec.ts | 37 ++++++++++++++-- frontend/e2e/ui-reduction-acceptance.spec.ts | 9 ++-- frontend/src/App.vue | 45 ++++++++++++++++++-- frontend/src/style.css | 4 +- frontend/src/style.test.ts | 27 +++++++++++- 5 files changed, 108 insertions(+), 14 deletions(-) diff --git a/frontend/e2e/trash-grouped-layout.spec.ts b/frontend/e2e/trash-grouped-layout.spec.ts index edf10fe..b404c38 100644 --- a/frontend/e2e/trash-grouped-layout.spec.ts +++ b/frontend/e2e/trash-grouped-layout.spec.ts @@ -1,5 +1,5 @@ import type { APIRequestContext, Page } from '@playwright/test' -import { expect, test } from './fixtures' +import { allowExpectedError, expect, test } from './fixtures' async function csrf(request: APIRequestContext) { const state = await request.storageState() @@ -51,10 +51,33 @@ test('Trash uses compact deadline groups without exposing child-level actions', const row = page.locator('.task-row--trash').filter({ hasText: title }) await expect(row).toHaveCount(1) await expect(row.getByRole('button', { name: '恢复' })).toBeVisible() - await expect(row.getByRole('button', { name: '永久删除' })).toBeVisible() + await expect(row.getByRole('button', { name: '打开任务操作' })).toBeVisible() expect(await row.locator('.task-check').count()).toBe(0) } - await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除') + + const overdueRow = page.locator('.task-row--trash').filter({ hasText: overdueTitle }) + await expect(overdueRow.locator('.task-tail')).toBeVisible() + const actionTrigger = overdueRow.getByRole('button', { name: '打开任务操作' }) + await actionTrigger.focus() + await actionTrigger.press('Enter') + const menu = page.getByRole('menu', { name: '回收站任务操作' }) + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() + await menu.getByRole('menuitem', { name: '永久删除' }).press('Tab') + await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() + await menu.getByRole('menuitem', { name: '永久删除' }).press('Shift+Tab') + await expect(menu.getByRole('menuitem', { name: '永久删除' })).toBeFocused() + await menu.getByRole('menuitem', { name: '永久删除' }).press('Escape') + await expect(menu).toBeHidden() + await expect(actionTrigger).toBeFocused() + await actionTrigger.click() + await page.getByRole('menuitem', { name: '永久删除' }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toContainText(`输入任务名称“${overdueTitle}”确认`) + await dialog.getByRole('button', { name: '永久删除' }).click() + await expect(dialog.getByRole('alert')).toContainText('任务名称不匹配') + await dialog.getByRole('button', { name: '取消' }).click() + await expect(actionTrigger).toBeFocused() const geometry = await page.evaluate(() => ({ viewport: innerWidth, @@ -69,4 +92,12 @@ test('Trash uses compact deadline groups without exposing child-level actions', expect(control.width).toBeGreaterThanOrEqual(44) expect(control.height).toBeGreaterThanOrEqual(44) } + + await actionTrigger.click() + await page.getByRole('menuitem', { name: '永久删除' }).click() + await dialog.getByLabel(`输入任务名称“${overdueTitle}”确认`).fill(overdueTitle) + allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`) + await dialog.getByRole('button', { name: '永久删除' }).click() + await expect(overdueRow).toHaveCount(0) + await expect(page.getByRole('heading', { name: '回收站', exact: true })).toBeFocused() }) diff --git a/frontend/e2e/ui-reduction-acceptance.spec.ts b/frontend/e2e/ui-reduction-acceptance.spec.ts index b0f15db..0a8ed76 100644 --- a/frontend/e2e/ui-reduction-acceptance.spec.ts +++ b/frontend/e2e/ui-reduction-acceptance.spec.ts @@ -103,24 +103,25 @@ test('task rows use the body for detail and Trash keeps distinct actions', async await openSidebarView(page, '回收站') await expect(page.locator('.trash-page-context')).toContainText('删除的任务保留在这里,可整组恢复或永久删除。') await expect(page.getByRole('heading', { name: '无截止日期', exact: true })).toBeVisible() - await expect(page.locator('.trash-safety-note')).toContainText('不支持子任务脱离父任务单独恢复或删除') const restoreRow = await taskRow(page, restoreTitle) const purgeRow = await taskRow(page, purgeTitle) for (const deletedRow of [restoreRow, purgeRow]) { await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible() - await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible() + await expect(deletedRow.getByRole('button', { name: '打开任务操作' })).toBeVisible() await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('role') await expect(deletedRow.locator('.task-main')).not.toHaveAttribute('tabindex') expect(await deletedRow.locator('.task-detail-trigger, .task-check').count()).toBe(0) } await restoreRow.getByRole('button', { name: '恢复' }).click() await expect(restoreRow).toHaveCount(0) - await purgeRow.getByRole('button', { name: '永久删除' }).click() + await purgeRow.getByRole('button', { name: '打开任务操作' }).click() + await page.getByRole('menuitem', { name: '永久删除' }).click() const purgeDialog = page.getByRole('dialog', { name: `永久删除“${purgeTitle}”?` }) await expect(purgeDialog).toBeVisible() // The UI can abort the completed 204 request while the confirmation overlay closes. allowExpectedError(page, `requestfailed: DELETE ${baseURL}/api/v1/trash/`) - await purgeDialog.getByRole('button', { name: '确认', exact: true }).click() + await purgeDialog.getByLabel(`输入任务名称“${purgeTitle}”确认`).fill(purgeTitle) + await purgeDialog.getByRole('button', { name: '永久删除', exact: true }).click() await expect(purgeRow).toHaveCount(0) await page.reload() await expect(page.locator('.task-row').filter({ hasText: restoreTitle })).toHaveCount(0) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4fd0af1..accaf1d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -57,6 +57,10 @@ const tasks = ref([]) const overdueTasks = ref([]) const trash = ref([]) const taskListElement = ref(null) +const trashAction = ref(null) +const trashMenu = ref(null) +const trashPageTitle = ref(null) +let trashActionTrigger: HTMLElement | null = null const NAVIGATION_STORAGE_KEY = 'dodo.navigation' const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY) const activeList = ref(restoredNavigation.listId) @@ -1163,12 +1167,45 @@ async function mutateTrashTask(task: Task, mutation: () => Promise, suc async function restoreTask(task: Task) { await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true) } +function openTrashAction(task: Task, event?: Event) { + trashActionTrigger = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null + trashAction.value = task + nextTick(() => trashMenu.value?.querySelector('[role=menuitem]')?.focus()) +} +function focusTrashMenuItem() { trashMenu.value?.querySelector('[role=menuitem]')?.focus() } +function focusTrashActionTrigger(trigger: HTMLElement | null) { + nextTick(() => { + if (trigger?.isConnected) trigger.focus() + else trashPageTitle.value?.focus() + }) +} +function closeTrashAction(restoreFocus = true) { + const trigger = trashActionTrigger + trashAction.value = null + trashActionTrigger = null + if (restoreFocus) focusTrashActionTrigger(trigger) +} +async function requestPurgeTask() { + const task = trashAction.value + const trigger = trashActionTrigger + closeTrashAction(false) + if (task) await purgeTask(task) + focusTrashActionTrigger(trigger) +} async function purgeTask(task: Task) { const childCount = task.subtasks?.length ?? 0 const impact = childCount ? `此任务及其 ${childCount} 个子任务将被永久删除,不能撤销。` : '此任务将被永久删除,不能撤销。' - if (!(await confirmAction(`永久删除“${task.title}”?`, impact, true))) return + const entered = await appDialog.value?.show({ + title: `永久删除“${task.title}”?`, + description: impact, + label: `输入任务名称“${task.title}”确认`, + confirmText: '永久删除', + danger: true, + validate: (value) => value.trim() === task.title ? null : '任务名称不匹配', + }) + if (typeof entered !== 'string') return await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false) } async function addSubtask() { @@ -1737,7 +1774,7 @@ onUnmounted(() => {
-

回收站

删除的任务保留在这里,可整组恢复或永久删除。

+

回收站

删除的任务保留在这里,可整组恢复或永久删除。

共 {{totalTasks}} 项
任务{{ totalTasks }}
@@ -1748,11 +1785,10 @@ onUnmounted(() => {
{{node.task.title}}含 {{node.subtasks.length}} 个子任务,整组处理
- +
-

父任务与子任务始终作为一个整体处理,不支持子任务脱离父任务单独恢复或删除。