feat: redesign task detail paper flow
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m37s

This commit is contained in:
2026-09-18 18:26:31 +08:00
parent 26f6ef31a6
commit 432a133a42
9 changed files with 263 additions and 45 deletions
+103
View File
@@ -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<APIRequestContext['fetch']>[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<HTMLElement>('.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)
})
+3
View File
@@ -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()
+2
View File
@@ -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'
+41 -27
View File
@@ -161,6 +161,8 @@ const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const recurrenceLoading = ref(false)
const savingSelectedTask = ref(false)
const removingSubtaskId = ref<string | null>(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<TaskRepeatConfig>(defaultRepeatConfig())
const selectedRepeatConfig = ref<TaskRepeatConfig>(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(() => {
</template>
</main>
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" @close="closeTaskDetail">
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
<div class="detail-form">
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
<fieldset class="detail-form" :disabled="taskDetailBusy">
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<div class="task-detail-due-row">
<span>截止时间</span>
<div class="task-detail-due-controls"><input v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input" type="date" aria-label="截止日期"><button v-if="selectedDueDate" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearSelectedDueDate"><X/></button><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip"><span>时间</span><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label></div>
<section class="task-detail-arrangement" aria-label="安排">
<label class="task-detail-field"><span class="task-detail-field-label">清单</span><select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<div class="task-detail-date-time">
<label class="task-detail-field"><span class="task-detail-field-label">截止日期</span><span class="task-detail-date-control"><input v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input" type="date" aria-label="截止日期"><button v-if="selectedDueDate" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click.prevent="clearSelectedDueDate"><X/></button></span></label>
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
</div>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></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>
<div class="field markdown">
</section>
<section class="field markdown task-detail-notes">
<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 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('bold')"><Bold/></button>
<button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button>
<button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button>
<button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button>
<button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button>
<button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button>
<button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button>
<button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button>
<button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button><button type="button" aria-label="粗体" title="粗体" @click="formatTaskNote('bold')"><Bold/></button><button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button><button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button><button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button><button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button><button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button><button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button><button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button><button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
</div>
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" placeholder="写备注选中文字后可用上方工具栏添加格式" @keydown="handleTaskNoteShortcut"/>
</div>
<div v-else class="markdown-preview" :class="{'markdown-preview-empty':!selectedTask.description.trim()}" v-html="selectedTask.description.trim() ? renderMarkdown(selectedTask.description) : '<p>暂无备注,切回编辑开始书写。</p>'"/>
</div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><div v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail"><button class="task-check subtask-check" type="button" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><span :class="{strike:subtask.completed}">{{subtask.title}}</span></div><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
<label>优先级<select v-model.number="selectedTask.priority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>
</div></details>
<div class="detail-actions"><button class="secondary" :disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
</div>
</section>
<section class="subtasks task-detail-subtasks"><div class="field-label"><span>子任务</span><button class="link" type="button" :disabled="taskDetailBusy" @click="addSubtask"><Plus/>添加</button></div><div v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail"><button class="task-check subtask-check" type="button" :disabled="taskDetailBusy" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><span :class="{strike:subtask.completed}">{{subtask.title}}</span><button class="subtask-remove" type="button" :disabled="taskDetailBusy" :aria-label="`删除子任务${subtask.title}`" @click="removeSubtask(subtask)"><Trash2/></button></div><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></section>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body"><label class="task-detail-field"><span class="task-detail-field-label">优先级</span><select v-model.number="selectedTask.priority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div></details>
</fieldset>
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
</AppSheet>
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" 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>
+14
View File
@@ -132,6 +132,20 @@ describe('AppSheet', () => {
expect(host.getAttribute('aria-hidden')).toBeNull()
})
it('places a newly opened stacked modal above the existing modal', async () => {
const host = document.createElement('main'); document.body.append(host)
const second = ref(false)
const app = createApp({ setup: () => () => h('div', [
h(AppSheet, { open:true, titleId:'layer-lower' }, { default:() => h('h2',{id:'layer-lower'},'lower') }),
h(AppSheet, { open:second.value, titleId:'layer-upper' }, { default:() => h('h2',{id:'layer-upper'},'upper') }),
]) })
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
second.value = true; await nextTick(); await nextTick()
const lower = document.querySelector<HTMLElement>('[aria-labelledby="layer-lower"]')!.parentElement!
const upper = document.querySelector<HTMLElement>('[aria-labelledby="layer-upper"]')!.parentElement!
expect(Number(upper.style.zIndex)).toBeGreaterThan(Number(lower.style.zIndex))
})
it('focuses prompt input and confirm dialog cancel action', async () => {
const host = document.createElement('div'); document.body.append(host)
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
+4 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { isTopOverlay, overlayRoot, popOverlay, pushOverlay } from '../composables/useOverlayStack'
import { isTopOverlay, overlayRoot, overlayZIndex, popOverlay, pushOverlay } from '../composables/useOverlayStack'
const props = withDefaults(defineProps<{
open: boolean
@@ -17,6 +17,7 @@ const props = withDefaults(defineProps<{
defineOptions({ inheritAttrs: false })
const emit = defineEmits<{ close: [] }>()
const panel = ref<HTMLElement | null>(null)
const layerZIndex = ref(80)
let overlayId: symbol | null = null
function requestClose() {
@@ -54,6 +55,7 @@ function focusIntoPanel() {
async function activate() {
if (!props.open || !props.modal || overlayId) return
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
layerZIndex.value = overlayZIndex(overlayId)
await nextTick()
if (!props.open || !props.modal || !overlayId) return
focusIntoPanel()
@@ -71,7 +73,7 @@ onBeforeUnmount(deactivate)
<template>
<Teleport v-if="modal" :to="overlayRoot()">
<div v-if="open" class="app-overlay app-sheet-mask" :aria-busy="busy || undefined" @click="scrimClose">
<div v-if="open" class="app-overlay app-sheet-mask" :style="{ zIndex: layerZIndex }" :aria-busy="busy || undefined" @click="scrimClose">
<component :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" aria-modal="true" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" tabindex="-1" v-bind="$attrs" @keydown="keydown">
<slot />
</component>
@@ -77,6 +77,11 @@ export function popOverlay(id: symbol) {
})
}
export function overlayZIndex(id: symbol | null) {
const index = id ? stack.findIndex((entry) => entry.id === id) : -1
return 80 + Math.max(0, index)
}
export function isTopOverlay(id: symbol | null) {
return Boolean(id && stack.at(-1)?.id === id)
}
+12 -1
View File
File diff suppressed because one or more lines are too long
+75 -11
View File
@@ -322,10 +322,70 @@ describe('approved UI detail direction', () => {
expect(css).not.toContain('.task-detail-trigger')
})
it('keeps task details on a 12px rhythm with fixed labels and right-aligned controls', () => {
expect(css).toContain('.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:12px}')
expect(css).toContain('.detail-form>label{grid-template-columns:80px 1fr;align-items:center}')
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
it('uses the approved paper-flow task detail structure and order', () => {
const detail = app.slice(app.indexOf('<AppSheet v-if="selectedTask"'), app.indexOf('</AppSheet>', app.indexOf('<AppSheet v-if="selectedTask"')))
const orderedMarkers = [
'class="detail-head"',
'class="detail-form"',
'class="detail-title"',
'class="task-detail-arrangement"',
'class="field markdown task-detail-notes"',
'class="subtasks task-detail-subtasks"',
'class="more-settings"',
'class="detail-actions"',
]
let cursor = -1
for (const marker of orderedMarkers) {
const next = detail.indexOf(marker)
expect(next).toBeGreaterThan(cursor)
cursor = next
}
expect(detail).toContain('<span class="task-detail-field-label">清单</span>')
expect(detail).toContain('<span class="task-detail-field-label">截止日期</span>')
expect(detail).toContain('<span class="task-detail-field-label">时间</span>')
expect(detail).toContain('<span class="task-detail-field-label">重复</span>')
expect(detail).toContain('<button class="danger-text detail-trash"')
expect(detail).toContain('<button class="primary detail-save"')
})
it('locks the task detail flow and exposes safe subtask removal semantics', () => {
const detail = app.slice(app.indexOf('<AppSheet v-if="selectedTask"'), app.indexOf('</AppSheet>', app.indexOf('<AppSheet v-if="selectedTask"')))
expect(detail).toContain(':busy="taskDetailBusy"')
expect(detail).toContain('@submit.prevent="saveSelectedTaskChanges"')
expect(detail).toContain('<button class="icon" type="button" :disabled="taskDetailBusy"')
expect(detail).toContain('<fieldset class="detail-form" :disabled="taskDetailBusy">')
expect(detail).toContain('class="subtask-remove" type="button"')
expect(detail).toContain('@click="removeSubtask(subtask)"')
expect(detail).toContain('<button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy"')
expect(detail).toContain('<button class="primary detail-save" type="submit"')
const buttons = [...detail.matchAll(/<button\b([^>]*)>/g)]
expect(buttons.length).toBeGreaterThan(0)
for (const [, attributes] of buttons) expect(attributes).toMatch(/\btype="(?:button|submit)"/)
const removeBlock = app.slice(app.indexOf('async function removeSubtask('), app.indexOf('function closeTaskDetail('))
expect(removeBlock).toContain('if (taskDetailBusy.value) return')
expect(removeBlock).toContain("await api(`/tasks/${subtask.id}`, { method: 'DELETE' })")
expect(removeBlock).toContain('parent.subtasks = (parent.subtasks ?? []).filter')
expect(removeBlock).toContain('tasks.value = tasks.value.filter')
expect(removeBlock).not.toContain('selectedTask.value = null')
})
it('matches the approved paper-flow geometry and material', () => {
expect(css).toContain('.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:236px minmax(0,1fr) 350px}')
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#fffdf8;overflow:hidden;display:flex;flex-direction:column}')
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;')
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;')
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:18px;border-bottom:1px solid #e4dbcf}')
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:10px}')
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
expect(css).toContain('.detail-save{min-width:88px}')
expect(css).toContain('.subtask-detail,.after-completion-fields,.repeat-custom-fields{width:100%;max-width:100%;min-width:0;border:0;border-radius:0;box-shadow:none;background:transparent;overflow:visible}')
expect(css).toContain('.subtask-remove{width:44px;height:44px;flex:0 0 44px;')
expect(css).toContain('@media(max-width:930px){.detail{position:relative;z-index:auto;left:auto;right:auto;top:auto;bottom:auto;width:100%;max-height:min(88dvh,760px);')
expect(css).toContain('.task-detail-date-time{grid-template-columns:minmax(0,1fr)}')
expect(css).toContain('.task-detail-date-control .task-compose-date-clear,.task-detail-time-control .task-compose-time-remove{min-width:44px;min-height:44px;width:44px;height:44px}')
})
it('opens Today habit bodies with keyboard parity while check clicks stay isolated', () => {
@@ -1177,11 +1237,12 @@ describe('task detail layout', () => {
expect(css).toContain('.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;')
})
it('keeps the list, due datetime and repeat controls equally compact', () => {
expect(app).toContain('<div class="task-detail-due-row">')
it('keeps the list, date, time, and repeat controls in the paper arrangement group', () => {
expect(app).toContain('<section class="task-detail-arrangement" aria-label="安排">')
expect(app).toContain('<div class="task-detail-date-time">')
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
expect(css).toContain('.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}')
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:10px}')
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}')
})
})
@@ -1218,7 +1279,7 @@ describe('unified floating add interaction', () => {
expect(app).toContain('const composeRepeat = ref')
expect(app).toContain('const selectedTaskRepeat = ref')
expect(app).toContain('重复<select v-model="composeRepeat"')
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
@@ -1247,9 +1308,12 @@ describe('unified floating add interaction', () => {
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
expect(saveBlock).toContain('savingSelectedTask.value = true')
expect(saveBlock).toContain('savingSelectedTask.value = false')
expect(saveBlock).toContain('} finally {\n savingSelectedTask.value = false\n }')
expect(saveBlock).not.toContain("if (recurrenceLoadToken === selectionToken && selectedTask.value?.id === taskId) savingSelectedTask.value = false")
const saveTaskBlock = app.slice(app.indexOf('async function saveTask('), app.indexOf('async function saveSelectedTaskChanges('))
expect(saveTaskBlock).toContain('if (selectionMatches && selectedTask.value?.id === task.id) fail(reason)')
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
expect(app).toContain(':disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges"')
expect(app).toContain('type="submit" :disabled="taskDetailBusy"')
expect(app).not.toContain('@blur="saveTask()"')
expect(app).not.toContain('@change="saveTask()"')
expect(app).not.toContain('value;saveTask()')
@@ -1422,7 +1486,7 @@ describe('quiet index sidebar parity', () => {
expect(css).toContain('.brand-row{height:68px;')
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:236px minmax(400px,1fr) minmax(280px,30vw)}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:236px minmax(0,1fr) 350px}')
expect(css).not.toContain('grid-template-columns:220px')
expect(css).toContain('@media(max-width:930px){.shell')
expect(css).toContain('left:0;width:236px;max-width:86vw;')