From 432a133a42743efd6429004b29dec00f6245c85f Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 18 Sep 2026 18:26:31 +0800 Subject: [PATCH] feat: redesign task detail paper flow --- frontend/e2e/task-detail-paper-flow.spec.ts | 103 ++++++++++++++++++++ frontend/e2e/visual-polish.spec.ts | 3 + frontend/playwright.config.ts | 2 + frontend/src/App.vue | 76 +++++++++------ frontend/src/components/AppSheet.test.ts | 14 +++ frontend/src/components/AppSheet.vue | 6 +- frontend/src/composables/useOverlayStack.ts | 5 + frontend/src/style.css | 13 ++- frontend/src/style.test.ts | 86 +++++++++++++--- 9 files changed, 263 insertions(+), 45 deletions(-) create mode 100644 frontend/e2e/task-detail-paper-flow.spec.ts diff --git a/frontend/e2e/task-detail-paper-flow.spec.ts b/frontend/e2e/task-detail-paper-flow.spec.ts new file mode 100644 index 0000000..f011003 --- /dev/null +++ b/frontend/e2e/task-detail-paper-flow.spec.ts @@ -0,0 +1,103 @@ +import type { APIRequestContext, Locator, Page } from '@playwright/test' +import { allowExpectedError, expect, test } from './fixtures' + +async function csrf(request: APIRequestContext) { + const state = await request.storageState() + return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? '' +} + +async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters[1]) { + return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } }) +} + +async function createFixture(request: APIRequestContext, baseURL: string, suffix: string) { + const bootstrap = await request.get('/api/v1/bootstrap') + expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy() + const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) + const title = `纸页顺流-${suffix}-${'很长的任务标题'.repeat(8)}` + const parentResponse = await mutate(request, baseURL, '/api/v1/tasks', { + method: 'POST', data: { title, description: `备注\n\n${'长内容 '.repeat(40)}`, list_id: inbox.id, due_at: '2030-06-18T08:30:00Z', due_has_time: true }, + }) + expect(parentResponse.ok(), await parentResponse.text()).toBeTruthy() + const parent = await parentResponse.json() as { id: string } + const childTitle = `子任务-${suffix}-/Users/example/${'unbroken-path/'.repeat(18)}file.txt` + const childResponse = await mutate(request, baseURL, '/api/v1/tasks', { method: 'POST', data: { title: childTitle, list_id: inbox.id, parent_id: parent.id } }) + expect(childResponse.ok(), await childResponse.text()).toBeTruthy() + return { title, childTitle } +} + +async function openDetail(page: Page, title: string) { + await page.goto('/') + const inbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }) + if ((await page.viewportSize())!.width <= 930) await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click() + await inbox.click() + const row = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) + await expect(row).toHaveCount(1) + await row.locator('.task-main').click() + const detail = page.getByRole('dialog', { name: '任务详情' }) + await expect(detail).toBeVisible() + await expect(detail.getByRole('button', { name: '保存更改' })).toBeEnabled() + return detail +} + +async function rect(locator: Locator) { + const value = await locator.boundingBox() + expect(value).not.toBeNull() + return value! +} + +test('task detail paper flow keeps approved responsive geometry and material', async ({ page, request, baseURL }, testInfo) => { + const fixture = await createFixture(request, baseURL!, `${testInfo.project.name}-${Date.now()}`) + const detail = await openDetail(page, fixture.title) + const viewport = page.viewportSize()! + const header = detail.locator('.detail-head') + const body = detail.locator('.detail-form') + const footer = detail.locator('.detail-actions') + const dateTime = detail.locator('.task-detail-date-time') + const nested = detail.locator('.subtask-detail,.after-completion-fields,.repeat-custom-fields') + const metrics = await detail.evaluate((element: HTMLElement) => { + const style = getComputedStyle(element) + const childrenInside = [...element.querySelectorAll('.detail-form > *')].every(child => child.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1) + return { width: element.getBoundingClientRect().width, maxHeight: style.maxHeight, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, childrenInside } + }) + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth) + expect(metrics.childrenInside).toBe(true) + expect((await rect(header)).height).toBeCloseTo(58, 0) + expect((await body.evaluate(el => parseFloat(getComputedStyle(el).paddingLeft)))).toBeCloseTo(18, 0) + const nestedStyles = await nested.evaluateAll(elements => elements.map(element => { const s = getComputedStyle(element); return { radius: s.borderRadius, shadow: s.boxShadow, background: s.backgroundColor } })) + for (const style of nestedStyles) { expect(style.radius).toBe('0px'); expect(style.shadow).toBe('none'); expect(style.background).toBe('rgba(0, 0, 0, 0)') } + for (const button of await detail.locator('button').all()) { + const box = await button.boundingBox() + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44) + } + const footerBox = await rect(footer) + const detailBox = await rect(detail) + expect(Math.abs((footerBox.y + footerBox.height) - (detailBox.y + detailBox.height))).toBeLessThanOrEqual(2) + + if (viewport.width >= 931) { + expect(metrics.width).toBeCloseTo(350, 0) + const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect())) + expect(columns[0].top).toBeCloseTo(columns[1].top, 0) + expect(columns[1].left - columns[0].right).toBeCloseTo(10, 0) + } else { + expect(metrics.width).toBeCloseTo(viewport.width, 0) + expect(parseFloat(metrics.maxHeight)).toBeLessThanOrEqual(viewport.height * .88 + 1) + const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect())) + expect(columns[1].top).toBeGreaterThanOrEqual(columns[0].bottom) + expect(columns[0].width).toBeCloseTo(columns[1].width, 0) + } +}) + +test('subtask removal keeps the parent detail open and removes only the child', async ({ page, request, baseURL }, testInfo) => { + const fixture = await createFixture(request, baseURL!, `remove-${testInfo.project.name}-${Date.now()}`) + const detail = await openDetail(page, fixture.title) + const child = detail.locator('.subtask-detail').filter({ hasText: fixture.childTitle }) + await expect(child).toHaveCount(1) + await child.getByRole('button', { name: `删除子任务${fixture.childTitle}` }).click() + const confirm = page.getByRole('dialog', { name: /删除子任务/ }) + allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/tasks/') + await confirm.getByRole('button', { name: '确认' }).click() + await expect(child).toHaveCount(0) + await expect(detail).toBeVisible() + await expect(detail.locator('textarea[aria-label="任务标题"]')).toHaveValue(fixture.title) +}) diff --git a/frontend/e2e/visual-polish.spec.ts b/frontend/e2e/visual-polish.spec.ts index 03746d5..d608f40 100644 --- a/frontend/e2e/visual-polish.spec.ts +++ b/frontend/e2e/visual-polish.spec.ts @@ -166,7 +166,10 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await taskRow.locator('.task-main').click() await expect(taskRow).toHaveClass(/selected/) await expect(taskRow).toHaveCSS('background-color', transparent) + const taskDetail = page.getByRole('dialog', { name: '任务详情' }) + await expect(taskDetail.getByRole('button', { name: '保存更改' })).toBeEnabled() await page.keyboard.press('Escape') + await expect(taskDetail).toBeHidden() await searchInput.press('Escape') const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' }) await expect(collapsedToggle).toBeFocused() diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 8d8c80f..d94b85e 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -9,6 +9,8 @@ const project = projectName === 'mobile-390' ? { name: 'mobile-375', testIgnore: /backup-roundtrip\.spec\.ts/, use: { viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } } : projectName === 'desktop-1440' ? { name: 'desktop-1440', use: { viewport: { width: 1440, height: 900 } } } + : projectName === 'desktop-931' + ? { name: 'desktop-931', use: { viewport: { width: 931, height: 900 } } } : projectName === 'desktop-721' ? { name: 'desktop-721', use: { viewport: { width: 721, height: 900 } } } : projectName === 'desktop-720' diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c614611..3187fac 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -161,6 +161,8 @@ const selectedRepeatError = ref('') const selectedTaskRecurrence = ref(null) const recurrenceLoading = ref(false) const savingSelectedTask = ref(false) +const removingSubtaskId = ref(null) +const taskDetailBusy = computed(() => savingSelectedTask.value || recurrenceLoading.value || removingSubtaskId.value !== null) const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' }) const composeRepeatConfig = ref(defaultRepeatConfig()) const selectedRepeatConfig = ref(defaultRepeatConfig()) @@ -943,7 +945,8 @@ async function saveTask(options?: { showSuccess?: boolean, expectedTaskId?: stri if (options?.showSuccess !== false) toast('已保存') return updated } catch (reason) { - fail(reason) + const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken + if (selectionMatches && selectedTask.value?.id === task.id) fail(reason) return false } } @@ -979,7 +982,9 @@ async function saveSelectedTaskChanges() { } } async function removeTask(task: Task) { + if (taskDetailBusy.value) return if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return + if (taskDetailBusy.value || selectedTask.value?.id !== task.id) return try { await api(`/tasks/${task.id}`, { method: 'DELETE' }) tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id) @@ -1019,7 +1024,25 @@ async function addSubtask() { if (!subtaskTitle) return try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) } } +async function removeSubtask(subtask: Task) { + if (taskDetailBusy.value) return + const parent = selectedTask.value + if (!parent || subtask.parent_id !== parent.id) return + if (!(await confirmAction(`删除子任务“${subtask.title}”?`, '子任务将移到回收站。', true))) return + if (taskDetailBusy.value || selectedTask.value?.id !== parent.id) return + removingSubtaskId.value = subtask.id + try { + await api(`/tasks/${subtask.id}`, { method: 'DELETE' }) + if (selectedTask.value?.id !== parent.id) return + parent.subtasks = (parent.subtasks ?? []).filter((item) => item.id !== subtask.id) + tasks.value = tasks.value.filter((item) => item.id !== subtask.id) + toast('子任务已删除') + } catch (reason) { fail(reason) } + finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null } +} function closeTaskDetail() { + if (taskDetailBusy.value) return + recurrenceLoadToken += 1 mobileDetail.value = false selectedTask.value = null } @@ -1598,44 +1621,35 @@ onUnmounted(() => { - -
任务详情
-
+ +
任务详情
+