style: simplify mobile task views
This commit is contained in:
@@ -15,7 +15,7 @@ test('settings are continuous, fit viewport, and controls are touch sized', asyn
|
|||||||
await bottomTab(page, '设置').click()
|
await bottomTab(page, '设置').click()
|
||||||
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
|
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
|
||||||
const groups = page.locator('.settings-group')
|
const groups = page.locator('.settings-group')
|
||||||
await expect(groups).toHaveCount(5)
|
await expect(groups).toHaveCount(4)
|
||||||
const layout = await page.locator('.settings-sections').evaluate(element => {
|
const layout = await page.locator('.settings-sections').evaluate(element => {
|
||||||
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
|
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||||
|
import { 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 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function bottomTab(page: Page, name: string) {
|
||||||
|
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidebarView(page: Page, name: string) {
|
||||||
|
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||||
|
await page.locator('.sidebar').getByRole('button', { name, exact: true }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNoHorizontalOverflow(page: Page, label: string, testInfo: { project: { name: string } }) {
|
||||||
|
const metrics = await page.evaluate(() => ({
|
||||||
|
viewport: { width: innerWidth, height: innerHeight },
|
||||||
|
document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth },
|
||||||
|
body: { clientWidth: document.body.clientWidth, scrollWidth: document.body.scrollWidth },
|
||||||
|
}))
|
||||||
|
console.log(`[qa-metrics][${testInfo.project.name}][${label}] ${JSON.stringify(metrics)}`)
|
||||||
|
expect(metrics.document.scrollWidth).toBe(metrics.document.clientWidth)
|
||||||
|
expect(metrics.body.scrollWidth).toBeLessThanOrEqual(metrics.body.clientWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function taskRow(page: Page, title: string) {
|
||||||
|
const row = page.locator('.task-row').filter({ has: page.locator('.task-main strong', { hasText: title }) })
|
||||||
|
await expect(row).toHaveCount(1)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask(request: APIRequestContext, baseURL: string, title: string, listId: string, dueAt?: string) {
|
||||||
|
const response = await mutate(request, baseURL, '/api/v1/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { title, list_id: listId, ...(dueAt ? { due_at: dueAt, due_has_time: false } : {}) },
|
||||||
|
})
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
|
return response.json() as Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCountdown(request: APIRequestContext, baseURL: string, title: string, eventDate: string, pinned = false) {
|
||||||
|
const response = await mutate(request, baseURL, '/api/v1/countdowns', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { title, event_date: eventDate, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned },
|
||||||
|
})
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
|
return response.json() as Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function box(locator: Locator) {
|
||||||
|
const value = await locator.boundingBox()
|
||||||
|
expect(value).not.toBeNull()
|
||||||
|
return value!
|
||||||
|
}
|
||||||
|
|
||||||
|
test('task rows use the body for detail and Trash keeps distinct actions', async ({ page, request, baseURL }, testInfo) => {
|
||||||
|
const suffix = testInfo.project.name
|
||||||
|
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||||
|
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||||
|
expect(inbox).toBeTruthy()
|
||||||
|
const todayTitle = `验收今天超长任务标题-${suffix}-用于确认正文获得更多实际可用宽度`
|
||||||
|
const inboxTitle = `验收清单任务-${suffix}`
|
||||||
|
const trashTitle = `验收回收站任务-${suffix}`
|
||||||
|
await createTask(request, baseURL!, inboxTitle, inbox.id)
|
||||||
|
const trash = await createTask(request, baseURL!, trashTitle, inbox.id)
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/tasks/${trash.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByRole('button', { name: '添加任务' }).click()
|
||||||
|
await page.getByLabel('任务名称').fill(todayTitle)
|
||||||
|
await page.getByRole('button', { name: '添加任务', exact: true }).click()
|
||||||
|
const todayRow = await taskRow(page, todayTitle)
|
||||||
|
expect(await todayRow.locator('.task-detail-trigger').count()).toBe(0)
|
||||||
|
const todayGeometry = await todayRow.evaluate(element => {
|
||||||
|
const row = element.getBoundingClientRect()
|
||||||
|
const main = element.querySelector<HTMLElement>('.task-main')!.getBoundingClientRect()
|
||||||
|
return { row: { x: row.x, width: row.width, right: row.right }, main: { x: main.x, width: main.width, right: main.right }, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth }
|
||||||
|
})
|
||||||
|
console.log(`[qa-metrics][${suffix}][today-task] ${JSON.stringify(todayGeometry)}`)
|
||||||
|
expect(todayGeometry.main.width).toBeGreaterThan(200)
|
||||||
|
expect(todayGeometry.scrollWidth).toBeLessThanOrEqual(todayGeometry.clientWidth)
|
||||||
|
await todayRow.locator('.task-main').click()
|
||||||
|
await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||||
|
|
||||||
|
await openSidebarView(page, '收集箱')
|
||||||
|
const inboxRow = await taskRow(page, inboxTitle)
|
||||||
|
expect(await inboxRow.locator('.task-detail-trigger').count()).toBe(0)
|
||||||
|
await inboxRow.locator('.task-main').click()
|
||||||
|
await expect(page.getByRole('dialog', { name: '任务详情' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||||
|
|
||||||
|
await openSidebarView(page, '回收站')
|
||||||
|
const deletedRow = await taskRow(page, trashTitle)
|
||||||
|
await expect(deletedRow.getByRole('button', { name: '恢复' })).toBeVisible()
|
||||||
|
await expect(deletedRow.getByRole('button', { name: '永久删除' })).toBeVisible()
|
||||||
|
expect(await deletedRow.locator('.task-detail-trigger').count()).toBe(0)
|
||||||
|
await expectNoHorizontalOverflow(page, 'tasks-trash', testInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Settings removes intro/empty danger and places mode-specific restore risk copy correctly', async ({ page }, testInfo) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await bottomTab(page, '设置').click()
|
||||||
|
expect(await page.locator('.view-intro').count()).toBe(0)
|
||||||
|
await expect(page.locator('.settings-group')).toHaveCount(4)
|
||||||
|
expect(await page.locator('.settings-danger').count()).toBe(0)
|
||||||
|
|
||||||
|
const input = page.locator('input[type=file]')
|
||||||
|
await input.setInputFiles({ name: 'acceptance.zip', mimeType: 'application/zip', buffer: Buffer.from('zip-placeholder') })
|
||||||
|
await page.getByLabel('恢复方式').selectOption('replace')
|
||||||
|
const warning = page.locator('.restore-replace-warning')
|
||||||
|
await expect(warning).toBeVisible()
|
||||||
|
const restoreRow = page.getByText('恢复方式', { exact: true }).locator('..').locator('..')
|
||||||
|
const zipGeometry = { row: await box(restoreRow), warning: await box(warning) }
|
||||||
|
console.log(`[qa-metrics][${testInfo.project.name}][settings-zip] ${JSON.stringify(zipGeometry)}`)
|
||||||
|
expect(Math.abs(zipGeometry.warning.y - zipGeometry.row.y)).toBeLessThan(zipGeometry.row.height)
|
||||||
|
|
||||||
|
await input.setInputFiles({ name: 'legacy.csv', mimeType: 'text/csv', buffer: Buffer.from('\ufefftitle\nlegacy') })
|
||||||
|
await expect(page.getByLabel('恢复方式')).toHaveValue('merge')
|
||||||
|
await expect(page.getByLabel('恢复方式')).toBeDisabled()
|
||||||
|
await expect(page.locator('.restore-replace-warning')).toHaveCount(0)
|
||||||
|
await expect(page.locator('.backup-preflight.legacy')).not.toContainText(/替换现有数据|替换并恢复/)
|
||||||
|
await expectNoHorizontalOverflow(page, 'settings', testInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Habits and Countdowns use reduced headers, compact rows, and continuous archive styling', async ({ page, request, baseURL }, testInfo) => {
|
||||||
|
const suffix = testInfo.project.name
|
||||||
|
const day = new Date().toLocaleDateString('sv-SE')
|
||||||
|
const focus = await createCountdown(request, baseURL!, `置顶倒数-${suffix}`, day, true)
|
||||||
|
await createCountdown(request, baseURL!, `普通倒数-${suffix}`, day)
|
||||||
|
const archived = await createCountdown(request, baseURL!, `归档倒数-${suffix}`, day)
|
||||||
|
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${archived.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||||
|
expect(focus.id).toBeTruthy()
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await bottomTab(page, '习惯').click()
|
||||||
|
expect(await page.locator('.view-intro').count()).toBe(0)
|
||||||
|
await expectNoHorizontalOverflow(page, 'habits', testInfo)
|
||||||
|
|
||||||
|
await bottomTab(page, '倒数日').click()
|
||||||
|
await expect(page.locator('.countdown-summary')).toContainText(/\d+ 个重要日子/)
|
||||||
|
expect(await page.locator('.countdown-hero').count()).toBe(0)
|
||||||
|
const activeRow = page.locator('.countdown-row').filter({ hasText: `普通倒数-${suffix}` })
|
||||||
|
await expect(activeRow).toHaveCount(1)
|
||||||
|
expect(await activeRow.locator('.countdown-main > small').count()).toBe(1)
|
||||||
|
expect(await activeRow.locator('.countdown-badges, .pinned-icon').count()).toBe(0)
|
||||||
|
const rowText = await activeRow.innerText()
|
||||||
|
expect(rowText).not.toMatch(/农历|不重复|每周|每月|每年/)
|
||||||
|
|
||||||
|
const toggle = page.getByRole('button', { name: /已归档(1)/ })
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
await expect(toggle).toHaveAttribute('aria-controls', 'archived-countdowns')
|
||||||
|
const toggleBox = await box(toggle)
|
||||||
|
expect(toggleBox.height).toBeGreaterThanOrEqual(44)
|
||||||
|
await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
const archive = page.locator('#archived-countdowns')
|
||||||
|
const archiveMetrics = await archive.evaluate(element => {
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
const articles = [...element.querySelectorAll<HTMLElement>('article')].map(article => {
|
||||||
|
const itemStyle = getComputedStyle(article)
|
||||||
|
return { borderRadius: itemStyle.borderRadius, boxShadow: itemStyle.boxShadow, borderTop: itemStyle.borderTopWidth, borderBottom: itemStyle.borderBottomWidth }
|
||||||
|
})
|
||||||
|
const buttons = [...element.querySelectorAll<HTMLElement>('button')].map(button => ({ text: button.innerText, ...button.getBoundingClientRect().toJSON() }))
|
||||||
|
return { container: { borderRadius: style.borderRadius, boxShadow: style.boxShadow }, articles, buttons }
|
||||||
|
})
|
||||||
|
console.log(`[qa-metrics][${testInfo.project.name}][countdown-archive] ${JSON.stringify(archiveMetrics)}`)
|
||||||
|
expect(archiveMetrics.container.boxShadow).toBe('none')
|
||||||
|
expect(archiveMetrics.articles.every(item => item.borderRadius === '0px' && item.boxShadow === 'none')).toBeTruthy()
|
||||||
|
expect(archiveMetrics.buttons.every(item => item.width >= 44 && item.height >= 44)).toBeTruthy()
|
||||||
|
await expectNoHorizontalOverflow(page, 'countdowns', testInfo)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Memo mobile search is one-row, focus-safe, persistent when collapsed, and desktop-wide', async ({ page }, testInfo) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await openSidebarView(page, '备忘录')
|
||||||
|
const toolbar = page.locator('.memo-toolbar')
|
||||||
|
const scope = page.locator('.memo-scope')
|
||||||
|
const toggle = page.getByRole('button', { name: '展开搜索备忘录' })
|
||||||
|
const collapsedGeometry = { toolbar: await box(toolbar), scope: await box(scope), toggle: await box(toggle) }
|
||||||
|
console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-collapsed] ${JSON.stringify(collapsedGeometry)}`)
|
||||||
|
expect(collapsedGeometry.toggle.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(collapsedGeometry.toggle.height).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(Math.abs(collapsedGeometry.scope.y - collapsedGeometry.toggle.y)).toBeLessThanOrEqual(4)
|
||||||
|
expect(collapsedGeometry.toolbar.height).toBeLessThanOrEqual(52)
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
|
||||||
|
await toggle.click()
|
||||||
|
const input = page.getByRole('textbox', { name: '搜索备忘录' })
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||||
|
await expect(input).toBeFocused()
|
||||||
|
await input.fill(`保留查询-${testInfo.project.name}`)
|
||||||
|
const openPanel = page.locator('#memo-search-panel')
|
||||||
|
const openMetrics = await openPanel.evaluate(element => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return { x: rect.x, right: rect.right, width: rect.width, scrollWidth: (element as HTMLElement).scrollWidth, clientWidth: (element as HTMLElement).clientWidth }
|
||||||
|
})
|
||||||
|
console.log(`[qa-metrics][${testInfo.project.name}][memo-mobile-open] ${JSON.stringify(openMetrics)}`)
|
||||||
|
expect(openMetrics.x).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(openMetrics.right).toBeLessThanOrEqual(page.viewportSize()!.width + 1)
|
||||||
|
expect(openMetrics.scrollWidth).toBeLessThanOrEqual(openMetrics.clientWidth)
|
||||||
|
|
||||||
|
await toggle.click()
|
||||||
|
await expect(openPanel).toBeHidden()
|
||||||
|
await expect(toggle).toBeFocused()
|
||||||
|
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
await toggle.click()
|
||||||
|
await expect(input).toHaveValue(`保留查询-${testInfo.project.name}`)
|
||||||
|
await expect(input).toBeFocused()
|
||||||
|
await expectNoHorizontalOverflow(page, 'memo-mobile', testInfo)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
await expect(openPanel).toBeVisible()
|
||||||
|
const desktopMetrics = await openPanel.evaluate(element => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const style = getComputedStyle(element)
|
||||||
|
return { viewport: { width: innerWidth, height: innerHeight }, x: rect.x, y: rect.y, width: rect.width, display: style.display, hidden: (element as HTMLElement).hidden, documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth }
|
||||||
|
})
|
||||||
|
console.log(`[qa-metrics][${testInfo.project.name}][memo-desktop] ${JSON.stringify(desktopMetrics)}`)
|
||||||
|
expect(desktopMetrics.hidden).toBeFalsy()
|
||||||
|
expect(desktopMetrics.width).toBeGreaterThanOrEqual(320)
|
||||||
|
expect(desktopMetrics.documentOverflow).toBe(0)
|
||||||
|
await expect(toggle).toBeHidden()
|
||||||
|
})
|
||||||
@@ -1523,7 +1523,7 @@ onUnmounted(() => {
|
|||||||
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
<button v-if="taskReorderMode" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
||||||
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
||||||
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
|
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
|
||||||
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>
|
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
|
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import CountdownPanel from './CountdownPanel.vue'
|
|||||||
import { invalidateCountdownCache } from './lib/mvp-utils'
|
import { invalidateCountdownCache } from './lib/mvp-utils'
|
||||||
|
|
||||||
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
|
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
|
||||||
|
const css = readFileSync('src/style.css', 'utf8')
|
||||||
const cleanups: Array<() => void> = []
|
const cleanups: Array<() => void> = []
|
||||||
const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' }
|
const countdown = { id:'c1', title:'发布日', event_date:'2026-09-20', display_date:'2026-09-20', kind:'countdown', repeat_rule:'none', icon:'', pinned:false, archived_at:null, days:4, calendar_mode:'solar', lunar_year:null, lunar_month:null, lunar_day:null, ignore_year:false, lunar_text:null, updated_at:'v1' }
|
||||||
const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 }
|
const countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 }
|
||||||
@@ -108,6 +109,23 @@ describe('countdown modal accessibility', () => {
|
|||||||
expect(source).not.toContain('<h3>{{group.title}}</h3>')
|
expect(source).not.toContain('<h3>{{group.title}}</h3>')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps active rows compact and archive controls accessible', () => {
|
||||||
|
expect(source).not.toContain('class="countdown-hero"')
|
||||||
|
expect(source).toContain('class="countdown-summary"')
|
||||||
|
expect(source).not.toContain('<template v-if="secondaryDate(item)">')
|
||||||
|
expect(source).not.toContain('visibleBadges(item)')
|
||||||
|
expect(source).not.toContain('class="pinned-icon"')
|
||||||
|
expect(source).toContain(':aria-expanded="showArchived"')
|
||||||
|
expect(source).toContain('aria-controls="archived-countdowns"')
|
||||||
|
expect(source).toContain('id="archived-countdowns"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps archived countdowns as one surface with separator-only mobile rows', () => {
|
||||||
|
expect(css).toMatch(/\.archived-countdowns\.archived-countdowns\{[^}]*background:var\(--surface-raised\);[^}]*border:1px solid var\(--border-cream\);[^}]*border-radius:var\(--radius-list\);[^}]*box-shadow:none/)
|
||||||
|
expect(css).toMatch(/\.archived-countdowns\.archived-countdowns article\{[^}]*border:0;[^}]*border-radius:0;[^}]*box-shadow:none/)
|
||||||
|
expect(css).toContain('.archived-countdowns.archived-countdowns article+article{border-top:1px solid var(--border-cream)}')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the empty state directly actionable', () => {
|
it('keeps the empty state directly actionable', () => {
|
||||||
expect(source).toContain('添加第一个重要日子')
|
expect(source).toContain('添加第一个重要日子')
|
||||||
expect(source).toContain('@click="openFromEmpty"')
|
expect(source).toContain('@click="openFromEmpty"')
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ function repeatBadge(item: Countdown) {
|
|||||||
if (item.calendar_mode === 'lunar') return item.ignore_year ? '每年' : (item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : '')
|
if (item.calendar_mode === 'lunar') return item.ignore_year ? '每年' : (item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : '')
|
||||||
return item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : ''
|
return item.repeat_rule !== 'none' ? repeatLabel(item.repeat_rule) : ''
|
||||||
}
|
}
|
||||||
function visibleBadges(item: Countdown) {
|
|
||||||
return [countdownKindLabel(item.kind), item.calendar_mode === 'lunar' ? '农历' : '', repeatBadge(item)].filter(Boolean).slice(0, 2)
|
|
||||||
}
|
|
||||||
function sorted(itemsToSort: Countdown[]) {
|
function sorted(itemsToSort: Countdown[]) {
|
||||||
return [...itemsToSort].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.days - b.days || a.title.localeCompare(b.title, 'zh-Hans-CN'))
|
return [...itemsToSort].sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.days - b.days || a.title.localeCompare(b.title, 'zh-Hans-CN'))
|
||||||
}
|
}
|
||||||
@@ -186,7 +183,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="countdown-view" :class="{ loading:busy }">
|
<section class="countdown-view" :class="{ loading:busy }">
|
||||||
<div class="countdown-content" :inert="open || Boolean(detailItem)" :aria-hidden="open || detailItem ? 'true' : undefined">
|
<div class="countdown-content" :inert="open || Boolean(detailItem)" :aria-hidden="open || detailItem ? 'true' : undefined">
|
||||||
<header class="countdown-hero"><small>{{countdownSummary}}</small></header>
|
<small class="countdown-summary">{{countdownSummary}}</small>
|
||||||
<p v-if="error" class="inline-error">{{error}}</p>
|
<p v-if="error" class="inline-error">{{error}}</p>
|
||||||
<div v-if="items.length" class="countdown-layout">
|
<div v-if="items.length" class="countdown-layout">
|
||||||
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
|
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
|
||||||
@@ -198,16 +195,15 @@ onBeforeUnmount(() => {
|
|||||||
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
|
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
|
||||||
<h3 v-if="group.title">{{group.title}}</h3>
|
<h3 v-if="group.title">{{group.title}}</h3>
|
||||||
<button v-for="item in group.items" :key="item.id" type="button" class="countdown-row" :class="[`kind-${item.kind}`, { pinned:item.pinned, today:item.days===0, past:item.days<0 }]" @click="openDetail(item)">
|
<button v-for="item in group.items" :key="item.id" type="button" class="countdown-row" :class="[`kind-${item.kind}`, { pinned:item.pinned, today:item.days===0, past:item.days<0 }]" @click="openDetail(item)">
|
||||||
<span class="countdown-main"><b>{{item.title}}</b><small>{{primaryDate(item)}}<template v-if="secondaryDate(item)"> · {{secondaryDate(item)}}</template></small><span class="countdown-badges"><em v-for="badge in visibleBadges(item)" :key="badge">{{badge}}</em></span></span>
|
<span class="countdown-main"><b>{{item.title}}<small v-if="item.pinned" class="countdown-pinned-mark"> · 置顶</small></b><small>{{primaryDate(item)}}</small></span>
|
||||||
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
|
<span class="countdown-state"><strong>{{item.days===0?'今天':Math.abs(item.days)}}</strong><small>{{countdownDayText(item.days)}}</small></span>
|
||||||
<Pin v-if="item.pinned" class="pinned-icon"/>
|
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
<div v-else-if="!busy" class="countdown-empty"><CalendarHeart/><b>还没有重要日子</b><span>生日、纪念日,或一场期待已久的旅行</span><button type="button" class="primary-small" @click="openFromEmpty">添加第一个重要日子</button></div>
|
||||||
<button v-if="archived.length" class="archived-toggle" :disabled="busy" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
<button v-if="archived.length" class="archived-toggle" type="button" :disabled="busy" :aria-expanded="showArchived" aria-controls="archived-countdowns" @click="showArchived=!showArchived"><ArchiveRestore/>已归档({{archived.length}})</button>
|
||||||
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
<div v-if="showArchived" id="archived-countdowns" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small></span><button :disabled="busy" @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" :disabled="busy" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||||
</div>
|
</div>
|
||||||
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
|
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
|
||||||
<template v-if="detailItem">
|
<template v-if="detailItem">
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { createApp, h, nextTick } from 'vue'
|
import { createApp, h, nextTick } from 'vue'
|
||||||
import MemoPanel from './MemoPanel.vue'
|
import MemoPanel from './MemoPanel.vue'
|
||||||
|
|
||||||
|
const source = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||||
|
const memoCss = readFileSync('src/memo.css', 'utf8')
|
||||||
|
|
||||||
const cleanups: Array<() => void> = []
|
const cleanups: Array<() => void> = []
|
||||||
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
const item = { id: 'm1', title: '第一条', excerpt: '摘要', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||||
|
|
||||||
@@ -35,6 +39,48 @@ async function mount(request: RequestMock, onNotice?: (message: string) => void,
|
|||||||
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
afterEach(() => { vi.useRealTimers(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||||
|
|
||||||
describe('MemoPanel', () => {
|
describe('MemoPanel', () => {
|
||||||
|
it('uses controlled mobile visibility, preserves query, and restores focus for Escape and toggle close', async () => {
|
||||||
|
const originalWidth = window.innerWidth
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||||
|
const request = vi.fn(async (): Promise<unknown> => ({ items: [], total: 0 }))
|
||||||
|
const { host } = await mount(request)
|
||||||
|
const toggle = host.querySelector<HTMLButtonElement>('[aria-label="展开搜索备忘录"]')!
|
||||||
|
const panel = host.querySelector<HTMLElement>('#memo-search-panel')!
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||||
|
expect(panel.hidden).toBe(true)
|
||||||
|
|
||||||
|
toggle.click(); await nextTick()
|
||||||
|
const input = host.querySelector<HTMLInputElement>('#memo-search-input')!
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||||
|
expect(panel.hidden).toBe(false)
|
||||||
|
expect(document.activeElement).toBe(input)
|
||||||
|
input.value = '保留'; input.dispatchEvent(new Event('input'))
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await nextTick()
|
||||||
|
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||||
|
expect(panel.hidden).toBe(true)
|
||||||
|
expect(input.value).toBe('保留')
|
||||||
|
expect(document.activeElement).toBe(toggle)
|
||||||
|
|
||||||
|
toggle.click(); await nextTick()
|
||||||
|
expect(document.activeElement).toBe(input)
|
||||||
|
toggle.click(); await nextTick()
|
||||||
|
expect(panel.hidden).toBe(true)
|
||||||
|
expect(input.value).toBe('保留')
|
||||||
|
expect(document.activeElement).toBe(toggle)
|
||||||
|
expect(host.querySelector('[data-scope="active"]')?.getAttribute('aria-selected')).toBe('true')
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps desktop search visible and at least 320px wide while mobile fills without overflow', () => {
|
||||||
|
expect(source).toContain('const mobileLayout = ref(window.innerWidth <= 930)')
|
||||||
|
expect(source).toContain(':hidden="mobileLayout && !mobileSearchOpen"')
|
||||||
|
expect(source).not.toContain('mobileSearchOpen || Boolean(query)')
|
||||||
|
expect(memoCss).toMatch(/\.memo-search-panel\{[^}]*flex:0 1 420px;[^}]*width:min\(420px,100%\);[^}]*min-width:320px/)
|
||||||
|
expect(memoCss).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.memo-search-panel\{[^}]*width:100%;[^}]*min-width:0/)
|
||||||
|
expect(memoCss).toContain('.memo-search-panel[hidden]{display:none}')
|
||||||
|
expect(memoCss).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.memo-search\{[^}]*width:100%;[^}]*min-width:0/)
|
||||||
|
})
|
||||||
|
|
||||||
it('loads active memos in server order and appends the next 50', async () => {
|
it('loads active memos in server order and appends the next 50', async () => {
|
||||||
const request = vi.fn(async (path: string) => path.includes('page=2')
|
const request = vi.fn(async (path: string) => path.includes('page=2')
|
||||||
? { items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 }
|
? { items: [{ ...item, id: 'm2', title: '第二页' }], total: 51 }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Archive, FileText, Search } from 'lucide-vue-next'
|
import { Archive, FileText, Search, X } from 'lucide-vue-next'
|
||||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||||
@@ -22,7 +22,10 @@ const selectedToken = ref(0)
|
|||||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||||
const searchInput = ref<HTMLInputElement | null>(null)
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
const mobileDetail = ref(window.innerWidth <= 930)
|
const searchToggle = ref<HTMLButtonElement | null>(null)
|
||||||
|
const mobileLayout = ref(window.innerWidth <= 930)
|
||||||
|
const mobileDetail = ref(mobileLayout.value)
|
||||||
|
const mobileSearchOpen = ref(false)
|
||||||
let detailOpener: HTMLElement | null = null
|
let detailOpener: HTMLElement | null = null
|
||||||
let timer: number | undefined
|
let timer: number | undefined
|
||||||
let generation = 0
|
let generation = 0
|
||||||
@@ -182,7 +185,14 @@ function removeItem(id: string, selectionToken: number) {
|
|||||||
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||||
}
|
}
|
||||||
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
|
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
|
||||||
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
|
function openMobileSearch() { mobileSearchOpen.value = true; void nextTick(() => searchInput.value?.focus()) }
|
||||||
|
function closeMobileSearch() { mobileSearchOpen.value = false; void nextTick(() => searchToggle.value?.focus()) }
|
||||||
|
function handleSearchKeydown(event: KeyboardEvent) { if (event.key === 'Escape' && mobileLayout.value) { event.preventDefault(); closeMobileSearch() } }
|
||||||
|
function clearSearch() { query.value = ''; void nextTick(() => searchInput.value?.focus()) }
|
||||||
|
function updateLayout() {
|
||||||
|
mobileLayout.value = window.innerWidth <= 930
|
||||||
|
mobileDetail.value = mobileLayout.value
|
||||||
|
}
|
||||||
watch(query, () => {
|
watch(query, () => {
|
||||||
generation += 1
|
generation += 1
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -201,7 +211,8 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
|||||||
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
|
||||||
<div class="memo-toolbar">
|
<div class="memo-toolbar">
|
||||||
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
|
||||||
<label class="memo-search"><Search/><input ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
|
<button ref="searchToggle" class="memo-search-toggle" type="button" aria-label="展开搜索备忘录" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
|
||||||
|
<div id="memo-search-panel" class="memo-search-panel" :class="{'is-open':mobileSearchOpen}" :hidden="mobileLayout && !mobileSearchOpen"><label class="memo-search"><Search/><input id="memo-search-input" ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…" @keydown="handleSearchKeydown"></label><button v-if="query" type="button" class="memo-search-clear" aria-label="清空搜索" @click="clearSearch"><X/></button></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
|
||||||
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录…</div>
|
||||||
|
|||||||
@@ -743,9 +743,6 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
<!-- 习惯(TickTick 风格:一次只操作一个习惯,不再逐格小按钮误触) -->
|
||||||
<template v-if="view === 'habits' || view === 'today-habits'">
|
<template v-if="view === 'habits' || view === 'today-habits'">
|
||||||
<header v-if="view === 'habits'" class="view-intro">
|
|
||||||
<small>把想坚持的事,变成每天的日常</small>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||||||
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
||||||
@@ -836,15 +833,11 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 设置与数据 -->
|
<!-- 设置与数据 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<header class="view-intro">
|
|
||||||
<div><small>备份、迁移与安全</small></div>
|
|
||||||
</header>
|
|
||||||
<div class="settings-sections">
|
<div class="settings-sections">
|
||||||
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件;CSV / JSON 继续用于旧格式兼容。</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
|
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件;CSV / JSON 继续用于旧格式兼容。</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small><small v-if="restoreMode==='replace'" class="restore-replace-warning">替换恢复会覆盖当前数据,请先导出完整备份。</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
|
||||||
<section class="settings-group"><header><h2>账户与安全</h2><p>修改密码后当前设备保持登录,其他设备自动退出。</p></header><form class="password-form settings-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></section>
|
<section class="settings-group"><header><h2>账户与安全</h2><p>修改密码后当前设备保持登录,其他设备自动退出。</p></header><form class="password-form settings-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></section>
|
||||||
<section class="settings-group"><header><h2>登录设备</h2><div class="session-card-actions"><p>可撤销其他设备的登录。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div></header><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
<section class="settings-group"><header><h2>登录设备</h2><div class="session-card-actions"><p>可撤销其他设备的登录。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div></header><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
||||||
<section class="settings-group"><header><h2>活动</h2><p>最近的账户和数据操作。</p></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
<section class="settings-group"><header><h2>活动</h2><p>最近的账户和数据操作。</p></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
||||||
<section class="settings-group settings-danger"><header><h2>危险操作</h2><p>替换恢复会覆盖当前数据,请先导出完整备份。</p></header></section>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<AppDialog ref="appDialog" />
|
<AppDialog ref="appDialog" />
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;min-width:min(320px,45%);display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-search-toggle{display:none}.memo-search-panel{flex:0 1 420px;width:min(420px,100%);min-width:320px;display:flex;align-items:center;gap:4px}.memo-search-clear{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent;color:var(--muted)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;flex:1 1 auto;width:100%;min-width:0;display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{align-items:stretch;flex-direction:column}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;gap:8px}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-search-toggle{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);color:var(--text-secondary)}.memo-search-panel{grid-column:1/-1;width:100%;min-width:0;max-height:0;opacity:0;overflow:hidden;pointer-events:none;display:flex;transition:max-height .18s ease,opacity .15s ease}.memo-search-panel.is-open{max-height:44px;opacity:1;pointer-events:auto}.memo-search-panel[hidden]{display:none}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+17
-11
@@ -36,7 +36,7 @@ describe('unified task due display', () => {
|
|||||||
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">')
|
expect(app).toContain('</span><span v-if="activeView===\'trash\'" class="task-actions">')
|
||||||
expect(app).not.toContain('restoreTask(subtask)')
|
expect(app).not.toContain('restoreTask(subtask)')
|
||||||
expect(app).not.toContain('purgeTask(subtask)')
|
expect(app).not.toContain('purgeTask(subtask)')
|
||||||
expect(app).toContain('<span v-else class="task-actions"><button class="icon ghost task-detail-trigger"')
|
expect(app).not.toContain('task-detail-trigger')
|
||||||
expect(app).toContain('<span v-if="node.task.priority" class="priority"')
|
expect(app).toContain('<span v-if="node.task.priority" class="priority"')
|
||||||
expect(app).toContain('v-if="node.subtasks.length" class="meta"')
|
expect(app).toContain('v-if="node.subtasks.length" class="meta"')
|
||||||
expect(app).toContain('<span class="meta-item"><ListChecks/>')
|
expect(app).toContain('<span class="meta-item"><ListChecks/>')
|
||||||
@@ -218,24 +218,26 @@ describe('solid cream material system', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('approved UI detail direction', () => {
|
describe('approved UI detail direction', () => {
|
||||||
it('uses one More detail entry for ordinary parent rows while preserving Trash and overdue actions', () => {
|
it('opens ordinary and overdue task details from the task body while preserving Trash actions', () => {
|
||||||
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
|
const ordinaryStart = app.indexOf('<section :id="activeView===\'today\' ? \'today-tasks\' : undefined"')
|
||||||
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
|
const ordinaryRows = app.slice(ordinaryStart, app.indexOf('</section>', ordinaryStart))
|
||||||
expect(ordinaryRows.match(/aria-label="打开任务详情"/g)).toHaveLength(1)
|
expect(ordinaryRows).toContain("selectTaskUnlessSwiped(node.task)")
|
||||||
expect(ordinaryRows).toContain('@click.stop="selectTask(node.task)"><Ellipsis/>')
|
expect(ordinaryRows).not.toContain('task-detail-trigger')
|
||||||
expect(ordinaryRows).not.toContain('selectTask(subtask)')
|
expect(ordinaryRows).not.toContain('selectTask(subtask)')
|
||||||
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
|
expect(ordinaryRows).not.toContain('aria-label="删除任务"')
|
||||||
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
|
expect(ordinaryRows).toContain('v-if="activeView===\'trash\'" class="task-actions"')
|
||||||
expect(ordinaryRows).toContain('restoreTask(node.task)')
|
expect(ordinaryRows).toContain('restoreTask(node.task)')
|
||||||
expect(ordinaryRows).toContain('purgeTask(node.task)')
|
expect(ordinaryRows).toContain('purgeTask(node.task)')
|
||||||
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
|
const overdue = app.slice(app.indexOf('<section v-if="overdueTaskTree.length"'), app.indexOf('id="today-tasks-heading"'))
|
||||||
|
expect(overdue).toContain('selectTaskUnlessSwiped(node.task)')
|
||||||
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
expect(overdue).not.toContain('aria-label="打开任务详情"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the parent due tail immediately before the More detail entry', () => {
|
it('keeps the due tail as the final parent-row control and opens details from the task body', () => {
|
||||||
expect(app).toContain('</span><span v-else class="task-actions"><button class="icon ghost task-detail-trigger" aria-label="打开任务详情" @click.stop="selectTask(node.task)"><Ellipsis/></button></span>')
|
expect(app).toContain('@click="activeView===\'trash\'?undefined:selectTaskUnlessSwiped(node.task)"')
|
||||||
expect(app).not.toContain('@click.stop="selectTask(subtask)"><Ellipsis/>')
|
expect(app).toContain('<span v-if="node.task.due_at" class="task-tail">')
|
||||||
expect(css).toContain('.task-detail-trigger:hover{color:var(--text-primary);background:#fff7eb}')
|
expect(app).not.toContain('task-detail-trigger')
|
||||||
|
expect(css).not.toContain('.task-detail-trigger')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps task details on a 12px rhythm with fixed labels and right-aligned controls', () => {
|
it('keeps task details on a 12px rhythm with fixed labels and right-aligned controls', () => {
|
||||||
@@ -986,17 +988,21 @@ describe('mobile touch targets', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('approved habit safety and U2 title hierarchy', () => {
|
describe('approved habit safety and U2 title hierarchy', () => {
|
||||||
it('uses one page title and continuous settings section headings', () => {
|
it('uses one page title and continuous settings section headings without slogan strips', () => {
|
||||||
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
||||||
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
||||||
for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
expect(mvpPanel).not.toContain('class="view-intro"')
|
||||||
|
for (const title of ['数据', '账户与安全', '登录设备', '活动']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
||||||
|
expect(mvpPanel).not.toContain('<h2>危险操作</h2>')
|
||||||
expect(mvpPanel).toContain('class="settings-sections"')
|
expect(mvpPanel).toContain('class="settings-sections"')
|
||||||
expect(mvpPanel).not.toContain('class="settings-grid"')
|
expect(mvpPanel).not.toContain('class="settings-grid"')
|
||||||
expect(mvpPanel).not.toContain('class="tool-card')
|
expect(mvpPanel).not.toContain('class="tool-card')
|
||||||
expect(css).toContain('.settings-sections{width:min(100%,760px);')
|
expect(css).toContain('.settings-sections{width:min(100%,760px);')
|
||||||
expect(css).toContain('.settings-row{min-height:56px;')
|
expect(css).toContain('.settings-row{min-height:56px;')
|
||||||
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
||||||
expect(css).toContain('.backup-preflight.invalid,.settings-danger{background:#fff2ef;')
|
expect(css).toContain('.backup-preflight.invalid{background:#fff2ef;')
|
||||||
|
expect(mvpPanel).toContain("v-if=\"restoreMode==='replace'\" class=\"restore-replace-warning\"")
|
||||||
|
expect(mvpPanel).toContain('替换恢复会覆盖当前数据,请先导出完整备份。')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user