feat: strengthen backup and mobile workflows
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { APIRequestContext, Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
import { unzipSync } from 'fflate'
|
||||
|
||||
function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
async function csrf(request: APIRequestContext, baseURL: string) {
|
||||
const state = await request.storageState()
|
||||
return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value
|
||||
?? state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value
|
||||
?? ''
|
||||
}
|
||||
|
||||
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
|
||||
const token = await csrf(request, baseURL)
|
||||
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': token, origin: baseURL } })
|
||||
}
|
||||
|
||||
test('complete ZIP backup preflights and replace-restores task, habit history, countdown, and attachment bytes', async ({ page, request, baseURL }, testInfo) => {
|
||||
const suffix = testInfo.project.name
|
||||
const taskTitle = `E2E 备份任务 ${suffix}`
|
||||
const habitName = `E2E 备份习惯 ${suffix}`
|
||||
const countdownTitle = `E2E 备份倒数日 ${suffix}`
|
||||
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||
expect(bootstrap.ok()).toBeTruthy()
|
||||
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||
expect(inbox).toBeTruthy()
|
||||
|
||||
const task = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id } })
|
||||
expect(task.ok()).toBeTruthy()
|
||||
const taskData = await task.json() as { id: string; version: number }
|
||||
const attachmentName = `原始附件-${suffix}.txt`
|
||||
const attachmentBytes = Buffer.from([0, 1, 2, 3, 10, 13, 127, 128, 254, 255])
|
||||
const attachmentResponse = await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}/attachments`, {
|
||||
method: 'POST', multipart: { file: { name: attachmentName, mimeType: 'text/plain', buffer: attachmentBytes } },
|
||||
})
|
||||
expect(attachmentResponse.ok()).toBeTruthy()
|
||||
const attachment = await attachmentResponse.json() as { id: string; filename: string; size: number; mime_type: string }
|
||||
expect(attachment).toMatchObject({ filename: attachmentName, size: attachmentBytes.length, mime_type: 'text/plain' })
|
||||
const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { method: 'POST', data: { name: habitName, kind: 'numeric', target: 2, max_value: 3, schedule_type: 'daily' } })
|
||||
expect(habitResponse.ok()).toBeTruthy()
|
||||
const habit = await habitResponse.json()
|
||||
const day = new Date().toLocaleDateString('sv-SE')
|
||||
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 2 } })).ok()).toBeTruthy()
|
||||
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false } })
|
||||
expect(countdownResponse.ok()).toBeTruthy()
|
||||
|
||||
await page.goto('/')
|
||||
await bottomTab(page, '设置').click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await page.getByRole('button', { name: '导出 ZIP' }).click()
|
||||
const download = await downloadPromise
|
||||
const zipPath = await download.path()
|
||||
expect(zipPath).not.toBeNull()
|
||||
const zipBytes = new Uint8Array(await readFile(zipPath!))
|
||||
const files = unzipSync(zipBytes)
|
||||
const manifest = JSON.parse(new TextDecoder().decode(files['manifest.json'])) as { format: string; version: number; entities: Record<string, number>; checksums: Record<string, string> }
|
||||
expect(manifest.format).toBe('dodo-backup')
|
||||
expect(manifest.version).toBe(2)
|
||||
expect(manifest.entities.tasks).toBeGreaterThan(0)
|
||||
expect(manifest.entities.habit_logs).toBeGreaterThan(0)
|
||||
expect(manifest.entities.attachments).toBeGreaterThan(0)
|
||||
const attachmentRows = JSON.parse(new TextDecoder().decode(files['data/attachments.json'])) as Array<{ id: string; task_id: string; filename: string; mime_type: string; size: number; archive_path: string }>
|
||||
const archivedAttachment = attachmentRows.find(item => item.id === attachment.id)
|
||||
expect(archivedAttachment).toMatchObject({ task_id: taskData.id, filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
|
||||
expect(Buffer.from(files[archivedAttachment!.archive_path])).toEqual(attachmentBytes)
|
||||
expect(manifest.checksums[archivedAttachment!.archive_path]).toBe(createHash('sha256').update(attachmentBytes).digest('hex'))
|
||||
for (const [entry, digest] of Object.entries(manifest.checksums)) {
|
||||
expect(files[entry], `declared ZIP entry ${entry}`).toBeTruthy()
|
||||
expect(createHash('sha256').update(files[entry]).digest('hex')).toBe(digest)
|
||||
}
|
||||
|
||||
expect((await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}`, { method: 'PATCH', data: { title: `${taskTitle} 已破坏`, version: taskData.version } })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 0 } })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/countdowns/${(await countdownResponse.json()).id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
expect((await mutate(request, baseURL!, `/api/v1/attachments/${attachment.id}`, { method: 'DELETE' })).ok()).toBeTruthy()
|
||||
expect(await (await request.get(`/api/v1/tasks/${taskData.id}/attachments`)).json()).toEqual([])
|
||||
|
||||
const chooser = page.locator('input[type=file]')
|
||||
await chooser.setInputFiles({ name: 'dodo-backup-v2.zip', mimeType: 'application/zip', buffer: Buffer.from(zipBytes) })
|
||||
await page.getByLabel('恢复方式').selectOption('replace')
|
||||
await page.getByRole('button', { name: '开始预检' }).click()
|
||||
const preflight = page.locator('.backup-preflight')
|
||||
await expect(preflight).toContainText('预检通过')
|
||||
await expect(preflight).toContainText('附件')
|
||||
await page.getByRole('button', { name: '替换并恢复' }).click()
|
||||
const confirm = page.getByRole('dialog', { name: '确认替换全部数据?' })
|
||||
await confirm.getByRole('button', { name: '确认', exact: true }).click()
|
||||
await expect(page.getByRole('status')).toContainText('数据已恢复')
|
||||
|
||||
const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`)
|
||||
expect(restoredTasksResponse.ok()).toBeTruthy()
|
||||
const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }>
|
||||
expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1)
|
||||
const restoredTask = restoredTasks.find(item => item.title === taskTitle)!
|
||||
const restoredAttachmentsResponse = await request.get(`/api/v1/tasks/${restoredTask.id}/attachments`)
|
||||
expect(restoredAttachmentsResponse.ok()).toBeTruthy()
|
||||
const restoredAttachments = await restoredAttachmentsResponse.json() as Array<{ id: string; filename: string; mime_type: string; size: number }>
|
||||
expect(restoredAttachments).toHaveLength(1)
|
||||
expect(restoredAttachments[0]).toMatchObject({ filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length })
|
||||
const restoredBlob = await request.get(`/api/v1/attachments/${restoredAttachments[0].id}`)
|
||||
expect(restoredBlob.ok()).toBeTruthy()
|
||||
expect(Buffer.from(await restoredBlob.body())).toEqual(attachmentBytes)
|
||||
await bottomTab(page, '习惯').click()
|
||||
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
|
||||
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
|
||||
await expect(page.locator('.habit-history__row')).toContainText('2 / 2')
|
||||
await page.getByRole('button', { name: '关闭习惯详情' }).click()
|
||||
await bottomTab(page, '倒数日').click()
|
||||
await expect(page.getByText(countdownTitle, { exact: true })).toHaveCount(1)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test as base } from '@playwright/test'
|
||||
|
||||
export const expectedErrors = new WeakMap<object, Set<string>>()
|
||||
export function allowExpectedError(page: object, fragment: string) {
|
||||
expectedErrors.get(page)?.add(fragment)
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page }, use) => {
|
||||
const failures: string[] = []
|
||||
const allowed = new Set<string>()
|
||||
expectedErrors.set(page, allowed)
|
||||
const record = (message: string) => {
|
||||
if (![...allowed].some(pattern => message.includes(pattern))) failures.push(message)
|
||||
}
|
||||
page.on('pageerror', error => record(`pageerror: ${error.message}`))
|
||||
page.on('console', message => { if (message.type() === 'error') record(`console.error: ${message.text()}`) })
|
||||
page.on('requestfailed', request => record(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`))
|
||||
page.on('response', response => {
|
||||
const url = new URL(response.url())
|
||||
const baseURL = new URL(page.url() || 'http://127.0.0.1:5173')
|
||||
if (url.origin === baseURL.origin && response.status() >= 400) record(`http ${response.status()}: ${response.request().method()} ${url.pathname}`)
|
||||
})
|
||||
await use(page)
|
||||
expect(failures, 'unexpected browser/runtime errors').toEqual([])
|
||||
},
|
||||
})
|
||||
|
||||
export { expect }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { chromium, type FullConfig } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export default async function globalSetup(config: FullConfig) {
|
||||
const projectName = process.env.DODO_E2E_PROJECT
|
||||
if (!projectName) throw new Error('DODO_E2E_PROJECT is required')
|
||||
const baseURL = config.projects[0]?.use.baseURL as string
|
||||
const storageState = path.resolve('playwright-runtime', projectName, 'auth.json')
|
||||
mkdirSync(path.dirname(storageState), { recursive: true })
|
||||
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const context = await browser.newContext({ baseURL })
|
||||
const page = await context.newPage()
|
||||
const readyDeadline = Date.now() + 90_000
|
||||
let ready = false
|
||||
while (Date.now() < readyDeadline) {
|
||||
try {
|
||||
const [health, frontend, proxy] = await Promise.all([
|
||||
page.request.get('/health/ready'), page.request.get('/'), page.request.get('/api/v1/setup/status'),
|
||||
])
|
||||
if (health.ok() && frontend.ok() && proxy.ok()) { ready = true; break }
|
||||
} catch {}
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
if (!ready) throw new Error('isolated frontend/backend/proxy did not become ready')
|
||||
const initialized = await page.request.get('/api/v1/setup/status')
|
||||
if (!initialized.ok()) throw new Error(`setup status failed: ${initialized.status()}`)
|
||||
if ((await initialized.json()).initialized) throw new Error(`isolated ${projectName} runtime was already initialized`)
|
||||
const response = await page.request.post('/api/v1/setup/initialize', { data: { username: `e2e-owner-${projectName}`, password: 'e2e-password-1234' } })
|
||||
if (!response.ok()) throw new Error(`initialize failed: ${response.status()} ${await response.text()}`)
|
||||
await context.storageState({ path: storageState })
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { allowExpectedError, expect, test } from './fixtures'
|
||||
|
||||
async function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
function taskRow(page: Page, title: string) {
|
||||
return page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
|
||||
}
|
||||
|
||||
async function assertInsideViewport(locator: Locator, page: Page) {
|
||||
const box = await locator.boundingBox()
|
||||
const viewport = page.viewportSize()
|
||||
expect(box).not.toBeNull()
|
||||
expect(viewport).not.toBeNull()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1)
|
||||
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1)
|
||||
}
|
||||
|
||||
test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => {
|
||||
const title = `E2E 今日任务 ${testInfo.project.name}`
|
||||
await page.goto('/')
|
||||
await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page')
|
||||
await page.getByRole('button', { name: '添加任务' }).click()
|
||||
await page.getByLabel('任务名称').fill(title)
|
||||
await page.getByRole('button', { name: '添加任务', exact: true }).click()
|
||||
|
||||
const row = taskRow(page, title)
|
||||
await expect(row).toHaveCount(1)
|
||||
const rowMain = row.locator('.task-main')
|
||||
await rowMain.click()
|
||||
const detail = page.getByRole('dialog', { name: '任务详情' })
|
||||
await expect(detail).toBeVisible()
|
||||
await assertInsideViewport(detail, page)
|
||||
await page.getByRole('button', { name: '关闭详情' }).click()
|
||||
await expect(rowMain).toBeFocused()
|
||||
|
||||
await row.getByRole('button', { name: `完成${title}` }).click()
|
||||
await expect(row).toHaveClass(/done/)
|
||||
await page.reload()
|
||||
const persisted = taskRow(page, title)
|
||||
await expect(persisted).toHaveCount(1)
|
||||
await expect(persisted.getByRole('button', { name: `重新打开${title}` })).toBeVisible()
|
||||
await persisted.getByRole('button', { name: `重新打开${title}` }).click()
|
||||
await expect(persisted.getByRole('button', { name: `完成${title}` })).toBeVisible()
|
||||
})
|
||||
|
||||
test('numeric habit records history, edits target, and continues', async ({ page }, testInfo) => {
|
||||
const name = `E2E 数量习惯 ${testInfo.project.name}`
|
||||
await page.goto('/')
|
||||
await (await bottomTab(page, '习惯')).click()
|
||||
await expect(await bottomTab(page, '习惯')).toHaveAttribute('aria-current', 'page')
|
||||
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||
await page.getByLabel('新习惯名称').fill(name)
|
||||
await page.getByLabel('习惯类型').selectOption('numeric')
|
||||
await page.getByLabel('目标值').fill('2')
|
||||
await page.getByRole('button', { name: '添加习惯', exact: true }).click()
|
||||
|
||||
const row = page.locator('.habit-row').filter({ hasText: name })
|
||||
await expect(row).toHaveCount(1)
|
||||
const check = row.getByRole('button', { name: `完成${name}一次` })
|
||||
await check.click()
|
||||
await expect(row).toContainText('1 / 2')
|
||||
await check.click()
|
||||
await expect(row).toContainText('2 / 2')
|
||||
await row.getByRole('button', { name: `查看习惯详情:${name}` }).click()
|
||||
const detail = page.getByRole('dialog', { name: name })
|
||||
await expect(detail.getByRole('heading', { name: '历史记录' })).toBeVisible()
|
||||
await expect(detail.locator('.habit-history__row')).toContainText('2 / 2')
|
||||
await detail.getByRole('button', { name: '编辑习惯' }).click()
|
||||
await page.getByLabel('目标值').fill('3')
|
||||
await page.getByRole('button', { name: '保存修改' }).click()
|
||||
await expect(row).toContainText('2 / 3')
|
||||
await row.getByRole('button', { name: `完成${name}一次` }).click()
|
||||
await expect(row).toContainText('3 / 3')
|
||||
await page.reload()
|
||||
const persistedRow = page.locator('.habit-row').filter({ hasText: name })
|
||||
await expect(persistedRow).toContainText('3 / 3')
|
||||
await persistedRow.getByRole('button', { name: `查看习惯详情:${name}` }).click()
|
||||
const persistedDetail = page.getByRole('dialog', { name })
|
||||
await expect(persistedDetail.locator('.habit-history__row')).toContainText('3 / 3')
|
||||
})
|
||||
|
||||
test('countdown archive and restore keeps one entity after refresh', async ({ page }, testInfo) => {
|
||||
const title = `E2E 倒数日 ${testInfo.project.name}`
|
||||
await page.goto('/')
|
||||
await (await bottomTab(page, '倒数日')).click()
|
||||
await page.getByRole('button', { name: '添加倒数日' }).click()
|
||||
await page.getByLabel('倒数日名称').fill(title)
|
||||
await page.getByRole('button', { name: '保存', exact: true }).click()
|
||||
|
||||
const item = page.getByRole('button').filter({ hasText: title })
|
||||
await expect(item).toHaveCount(1)
|
||||
await item.click()
|
||||
const detail = page.getByRole('dialog', { name: title })
|
||||
await expect(detail).toBeVisible()
|
||||
// The UI intentionally aborts its DELETE fetch after the 204 response while closing the detail sheet.
|
||||
allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/countdowns/')
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => response.url().includes(`/api/v1/countdowns/`) && response.request().method() === 'DELETE' && response.status() === 204),
|
||||
detail.getByRole('button', { name: '归档' }).click(),
|
||||
])
|
||||
await page.getByRole('button', { name: /已归档(1)/ }).click()
|
||||
const archived = page.locator('.archived-countdowns article').filter({ hasText: title })
|
||||
await expect(archived).toHaveCount(1)
|
||||
await archived.getByRole('button', { name: '恢复' }).click()
|
||||
await page.reload()
|
||||
await expect(page.getByText(title, { exact: true })).toHaveCount(1)
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, test } from './fixtures'
|
||||
|
||||
function bottomTab(page: Page, name: string) {
|
||||
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
async function openTaskComposer(page: Page) {
|
||||
await page.getByRole('button', { name: '添加任务' }).click()
|
||||
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
|
||||
}
|
||||
|
||||
test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await bottomTab(page, '设置').click()
|
||||
await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page')
|
||||
const groups = page.locator('.settings-group')
|
||||
await expect(groups).toHaveCount(5)
|
||||
const layout = await page.locator('.settings-sections').evaluate(element => {
|
||||
const groups = [...element.querySelectorAll<HTMLElement>(':scope > .settings-group')]
|
||||
return {
|
||||
bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom),
|
||||
}
|
||||
})
|
||||
expect(layout.bodyOverflow).toBe(0)
|
||||
expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy()
|
||||
const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ })
|
||||
for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) {
|
||||
const box = await target.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
for (const target of await sessionButtons.all()) {
|
||||
const box = await target.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
}
|
||||
})
|
||||
|
||||
test('task, habit, countdown, memo, action and confirmation overlays share the modal contract', async ({ page }, testInfo) => {
|
||||
await page.goto('/')
|
||||
const assertModal = async (dialog: ReturnType<Page['getByRole']>) => {
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.locator('#app')).toHaveAttribute('inert', '')
|
||||
const metrics = await dialog.evaluate(element => {
|
||||
const panel = element as HTMLElement
|
||||
const header = panel.querySelector<HTMLElement>('.app-sheet__header, header')
|
||||
const footer = panel.querySelector<HTMLElement>('.app-sheet__footer, footer')
|
||||
return {
|
||||
horizontalOverflow: panel.scrollWidth - panel.clientWidth,
|
||||
headerVisible: !header || header.getBoundingClientRect().top >= 0,
|
||||
footerVisible: !footer || footer.getBoundingClientRect().bottom <= innerHeight + 1,
|
||||
}
|
||||
})
|
||||
expect(metrics).toEqual({ horizontalOverflow: 0, headerVisible: true, footerVisible: true })
|
||||
}
|
||||
|
||||
await openTaskComposer(page)
|
||||
await assertModal(page.getByRole('dialog', { name: /添加(?:今天)?任务/ }))
|
||||
await page.keyboard.press('Escape')
|
||||
await bottomTab(page, '习惯').click()
|
||||
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||
await assertModal(page.getByRole('dialog', { name: '添加习惯' }))
|
||||
await page.keyboard.press('Escape')
|
||||
await bottomTab(page, '倒数日').click()
|
||||
await page.getByRole('button', { name: '添加倒数日' }).click()
|
||||
await assertModal(page.getByRole('dialog', { name: '新建倒数日' }))
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||
await page.locator('.sidebar').getByRole('button', { name: '备忘录', exact: true }).click()
|
||||
await page.getByRole('button', { name: '添加备忘录' }).click()
|
||||
await assertModal(page.getByRole('dialog', { name: '备忘录详情' }))
|
||||
await page.getByLabel('备忘录标题').fill(`未保存 ${testInfo.project.name}`)
|
||||
await page.getByRole('button', { name: '关闭备忘录' }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: '放弃未保存的更改?' })
|
||||
await assertModal(confirmation)
|
||||
await confirmation.getByRole('button', { name: '取消' }).click()
|
||||
await expect(page.getByRole('dialog', { name: '备忘录详情' })).toBeVisible()
|
||||
await expect(page.getByLabel('备忘录标题')).toBeFocused()
|
||||
})
|
||||
|
||||
test('AppSheet traps focus, Escape closes, and scrim owns outside hit testing', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
const opener = page.getByRole('button', { name: '添加任务' })
|
||||
await opener.focus()
|
||||
const dialog = await openTaskComposer(page)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(page.getByLabel('任务名称')).toBeFocused()
|
||||
const background = page.locator('#app')
|
||||
await expect(background).toHaveAttribute('aria-hidden', 'true')
|
||||
await expect(background).toHaveAttribute('inert', '')
|
||||
const geometry = await page.locator('.app-overlay').evaluate(element => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(2, 2)
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, hitIsScrim: hit === element }
|
||||
})
|
||||
expect(geometry).toEqual({ x: 0, y: 0, width: page.viewportSize()!.width, height: page.viewportSize()!.height, hitIsScrim: true })
|
||||
|
||||
const focusable = dialog.locator('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])').filter({ visible: true })
|
||||
const first = focusable.first()
|
||||
const last = focusable.last()
|
||||
await first.focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await expect(last).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(first).toBeFocused()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(opener).toBeFocused()
|
||||
|
||||
await openTaskComposer(page)
|
||||
await page.mouse.click(2, 2)
|
||||
await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toBeHidden()
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
||||
{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"[email protected]","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run --exclude 'e2e/**'","test:e2e:mobile":"node scripts/playwright-mobile.mjs"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@playwright/test":"^1.63.0","@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","fflate":"^0.8.3","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const projectName = process.env.DODO_E2E_PROJECT
|
||||
if (!projectName) throw new Error('DODO_E2E_PROJECT is required; use pnpm test:e2e:mobile')
|
||||
const project = projectName === 'mobile-390'
|
||||
? { name: 'mobile-390', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true } }
|
||||
: projectName === 'mobile-375'
|
||||
? { name: 'mobile-375', testIgnore: /backup-roundtrip\.spec\.ts/, use: { viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } }
|
||||
: null
|
||||
if (!project) throw new Error(`unknown DODO_E2E_PROJECT: ${projectName}`)
|
||||
|
||||
const runtimeRoot = path.resolve('playwright-runtime', projectName)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 8_000 },
|
||||
outputDir: path.join(runtimeRoot, 'test-results'),
|
||||
reporter: [['line'], ['html', { outputFolder: path.join(runtimeRoot, 'report'), open: 'never' }]],
|
||||
globalSetup: './e2e/global-setup.ts',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
storageState: path.join(runtimeRoot, 'auth.json'),
|
||||
reducedMotion: 'reduce',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [project],
|
||||
webServer: {
|
||||
command: 'node scripts/playwright-mobile-server.mjs',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
})
|
||||
Generated
+36
@@ -39,6 +39,9 @@ importers:
|
||||
specifier: latest
|
||||
version: 5.3.1(@vue/[email protected])([email protected])([email protected](@types/[email protected])([email protected]))([email protected]([email protected]))
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.63.0
|
||||
version: 1.63.0
|
||||
'@tailwindcss/vite':
|
||||
specifier: latest
|
||||
version: 4.3.3([email protected](@types/[email protected])([email protected]))
|
||||
@@ -48,6 +51,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: latest
|
||||
version: 26.4.1
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
jsdom:
|
||||
specifier: ^30.0.1
|
||||
version: 30.0.1
|
||||
@@ -180,6 +186,11 @@ packages:
|
||||
'@oxc-project/[email protected]':
|
||||
resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -614,6 +625,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -878,6 +892,16 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -1303,6 +1327,10 @@ snapshots:
|
||||
|
||||
'@oxc-project/[email protected]': {}
|
||||
|
||||
'@playwright/[email protected]':
|
||||
dependencies:
|
||||
playwright: 1.63.0
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -1674,6 +1702,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.7
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -1901,6 +1931,12 @@ snapshots:
|
||||
exsolve: 1.1.1
|
||||
pathe: 2.0.3
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
playwright-core: 1.63.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const frontend = process.cwd()
|
||||
const root = path.resolve(frontend, '..')
|
||||
const project = process.env.DODO_E2E_PROJECT
|
||||
if (!project) throw new Error('DODO_E2E_PROJECT is required')
|
||||
const runtimeBase = path.join(frontend, 'playwright-runtime', project)
|
||||
const runtime = path.join(runtimeBase, `run-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`)
|
||||
const attachments = path.join(runtime, 'attachments')
|
||||
const staging = path.join(runtime, 'backup-staging')
|
||||
mkdirSync(attachments, { recursive: true })
|
||||
mkdirSync(staging, { recursive: true })
|
||||
writeFileSync(path.join(runtimeBase, 'latest.json'), JSON.stringify({ runtime, database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2))
|
||||
writeFileSync(path.join(runtime, 'runtime.json'), JSON.stringify({ database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2))
|
||||
|
||||
const logs = {
|
||||
backend: createWriteStream(path.join(runtime, 'backend.log'), { flags: 'a' }),
|
||||
frontend: createWriteStream(path.join(runtime, 'frontend.log'), { flags: 'a' }),
|
||||
}
|
||||
const children = []
|
||||
function start(command, args, options, log) {
|
||||
const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
child.stdout.pipe(log)
|
||||
child.stderr.pipe(log)
|
||||
children.push(child)
|
||||
return child
|
||||
}
|
||||
|
||||
start(path.join(root, '.venv/bin/uvicorn'), ['backend.main:app', '--host', '127.0.0.1', '--port', '8781'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
DODO_DATABASE_URL: `sqlite+aiosqlite:///${path.join(runtime, 'dodo.sqlite3')}`,
|
||||
DODO_AUTO_CREATE_SCHEMA: 'true',
|
||||
DODO_COOKIE_SECURE: 'false',
|
||||
DODO_ATTACHMENT_DIR: attachments,
|
||||
DODO_BACKUP_STAGING_DIR: staging,
|
||||
},
|
||||
}, logs.backend)
|
||||
start('pnpm', ['exec', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], { cwd: frontend, env: process.env }, logs.frontend)
|
||||
|
||||
let stopping = false
|
||||
function stop(signal = 'SIGTERM') {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
for (const child of children) if (!child.killed) child.kill(signal)
|
||||
setTimeout(() => { for (const child of children) if (!child.killed) child.kill('SIGKILL') }, 3000).unref()
|
||||
}
|
||||
process.on('SIGTERM', () => stop())
|
||||
process.on('SIGINT', () => stop())
|
||||
process.on('exit', () => stop())
|
||||
|
||||
await Promise.all(children.map(child => new Promise((resolve, reject) => {
|
||||
child.once('exit', (code, signal) => stopping ? resolve() : reject(new Error(`server exited code=${code} signal=${signal}`)))
|
||||
})))
|
||||
@@ -0,0 +1,15 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const projects = ['mobile-390', 'mobile-375']
|
||||
for (const project of projects) {
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const child = spawn('pnpm', ['exec', 'playwright', 'test', '--project', project], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DODO_E2E_PROJECT: project },
|
||||
stdio: 'inherit',
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('exit', value => resolve(value ?? 1))
|
||||
})
|
||||
if (code !== 0) process.exit(code)
|
||||
}
|
||||
+35
-79
@@ -11,7 +11,6 @@ import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
@@ -22,6 +21,8 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.vue'
|
||||
import TaskDueDisplay from './components/TaskDueDisplay.vue'
|
||||
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||
|
||||
@@ -49,8 +50,6 @@ let archivedListActionTrigger: HTMLElement | null = null
|
||||
const purgeListTarget = ref<TaskList | null>(null)
|
||||
const purgeListSubmitting = ref(false)
|
||||
const purgeListError = ref('')
|
||||
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
|
||||
const purgeListDialog = ref<HTMLElement | null>(null)
|
||||
let purgeListTrigger: HTMLElement | null = null
|
||||
const tasks = ref<Task[]>([])
|
||||
const overdueTasks = ref<Task[]>([])
|
||||
@@ -93,7 +92,6 @@ const taskDueNowMs = useTaskDueClock()
|
||||
const mobileSidebar = ref(false)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const mobileDetail = ref(false)
|
||||
const mobileMore = ref(false)
|
||||
const moreSettingsOpen = ref(false)
|
||||
const markdownPreview = ref(false)
|
||||
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -296,39 +294,16 @@ function toggleSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalLabel = ref('')
|
||||
const modalValue = ref('')
|
||||
const modalError = ref('')
|
||||
const modalConfirmText = ref('确定')
|
||||
const modalResolve = ref<((value: string | null) => void) | null>(null)
|
||||
function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
modalTitle.value = title
|
||||
modalLabel.value = label
|
||||
modalValue.value = initial
|
||||
modalError.value = ''
|
||||
modalConfirmText.value = confirmText
|
||||
modalVisible.value = true
|
||||
modalResolve.value = resolve
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
async function confirmAction(title: string, description?: string, danger = false) {
|
||||
return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true
|
||||
}
|
||||
async function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||||
const result = await appDialog.value?.show({
|
||||
title, label, initial, confirmText,
|
||||
validate: label ? (value) => normalizeRequiredName(value).error : undefined,
|
||||
})
|
||||
}
|
||||
function closeModal() {
|
||||
modalVisible.value = false
|
||||
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
|
||||
}
|
||||
function confirmModal() {
|
||||
if (modalLabel.value) {
|
||||
const normalized = normalizeRequiredName(modalValue.value)
|
||||
if (normalized.error) {
|
||||
modalError.value = normalized.error
|
||||
return
|
||||
}
|
||||
modalValue.value = normalized.value
|
||||
}
|
||||
modalVisible.value = false
|
||||
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
|
||||
return typeof result === 'string' ? result.trim() : null
|
||||
}
|
||||
|
||||
const activeName = computed(() => {
|
||||
@@ -679,7 +654,7 @@ async function loadTrash() {
|
||||
})
|
||||
}
|
||||
async function switchView(view: View, listId?: string) {
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
||||
taskMutationNavigation.value += 1
|
||||
taskReorderMode.value = false
|
||||
cancelTaskReorder()
|
||||
@@ -699,7 +674,7 @@ async function switchView(view: View, listId?: string) {
|
||||
if (listId) activeList.value = listId
|
||||
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
||||
page.value = 1
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||
if (view !== 'memos') memoDetailOpen.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
else if (view === 'today') await loadTodayView()
|
||||
@@ -983,7 +958,7 @@ async function saveSelectedTaskChanges() {
|
||||
}
|
||||
}
|
||||
async function removeTask(task: Task) {
|
||||
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
||||
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
|
||||
try {
|
||||
await api(`/tasks/${task.id}`, { method: 'DELETE' })
|
||||
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
|
||||
@@ -1014,7 +989,7 @@ async function restoreTask(task: Task) {
|
||||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
|
||||
}
|
||||
async function purgeTask(task: Task) {
|
||||
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
|
||||
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
|
||||
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
|
||||
}
|
||||
async function addSubtask() {
|
||||
@@ -1151,7 +1126,6 @@ function openPurgeList(item: TaskList) {
|
||||
purgeListError.value = ''
|
||||
archivedListAction.value = null
|
||||
archivedListActionTrigger = null
|
||||
nextTick(() => purgeCancelButton.value?.focus())
|
||||
}
|
||||
function focusPurgeListTrigger() {
|
||||
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
|
||||
@@ -1164,15 +1138,6 @@ function closePurgeList() {
|
||||
purgeListError.value = ''
|
||||
focusPurgeListTrigger()
|
||||
}
|
||||
function handlePurgeDialogKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
|
||||
if (event.key !== 'Tab' || !purgeListDialog.value) return
|
||||
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
|
||||
if (!controls.length) return
|
||||
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
|
||||
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
|
||||
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
|
||||
}
|
||||
async function confirmPurgeList() {
|
||||
if (!purgeListTarget.value || purgeListSubmitting.value) return
|
||||
purgeListSubmitting.value = true
|
||||
@@ -1467,8 +1432,8 @@ onUnmounted(() => {
|
||||
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</nav>
|
||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction">
|
||||
<section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`">
|
||||
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
|
||||
<template v-if="sidebarAction">
|
||||
<template v-if="!listMoveMenuOpen">
|
||||
<header class="app-sheet__header sidebar-action-header">
|
||||
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
|
||||
@@ -1505,8 +1470,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</AppSheet>
|
||||
</aside>
|
||||
|
||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||
@@ -1573,8 +1538,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
|
||||
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||
<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">
|
||||
<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>
|
||||
@@ -1608,14 +1573,11 @@ onUnmounted(() => {
|
||||
</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>
|
||||
</aside>
|
||||
</AppSheet>
|
||||
|
||||
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
||||
<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>
|
||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||
<Transition name="task-compose">
|
||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||||
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
||||
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
||||
@@ -1638,28 +1600,22 @@ onUnmounted(() => {
|
||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||
<Teleport to="body">
|
||||
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
|
||||
</Teleport>
|
||||
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
|
||||
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
|
||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
||||
<div class="modal-box" role="dialog" aria-modal="true">
|
||||
<h3>{{ modalTitle }}</h3>
|
||||
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus :aria-invalid="Boolean(modalError)" aria-describedby="modal-name-error" @input="modalError=''" @keyup.enter="confirmModal"><small v-if="modalError" id="modal-name-error" role="alert" class="field-error">{{ modalError }}</small></label>
|
||||
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
|
||||
<template v-if="purgeListTarget">
|
||||
<div class="app-sheet__body">
|
||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import CountdownPanel from './CountdownPanel.vue'
|
||||
import { invalidateCountdownCache } from './lib/mvp-utils'
|
||||
|
||||
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
|
||||
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 countdownB = { ...countdown, id:'c2', title:'旅行日', event_date:'2026-09-24', display_date:'2026-09-24', days:8 }
|
||||
function deferred<T>() { let resolve!: (value:T)=>void; let reject!: (reason?:unknown)=>void; const promise = new Promise<T>((yes,no)=>{ resolve=yes; reject=no }); return { promise, resolve, reject } }
|
||||
async function flush() { await Promise.resolve(); await new Promise((resolve)=>setTimeout(resolve, 0)); await Promise.resolve(); await nextTick() }
|
||||
async function mountWithFetch(fetchMock: ReturnType<typeof vi.fn>) {
|
||||
invalidateCountdownCache()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const notices:string[]=[]
|
||||
const host=document.createElement('div'); document.body.append(host)
|
||||
const app=createApp(()=>h(CountdownPanel,{ onNotice:(message:string)=>notices.push(message) })); app.mount(host)
|
||||
const unmount=()=>{ app.unmount(); host.remove() }; cleanups.push(unmount)
|
||||
await flush(); return { host, notices, unmount }
|
||||
}
|
||||
function clickCountdown(host:HTMLElement, title:string) {
|
||||
const button=[...host.querySelectorAll<HTMLButtonElement>('.countdown-focus,.countdown-row')].find((candidate)=>candidate.textContent?.includes(title))
|
||||
expect(button).toBeTruthy(); button!.click()
|
||||
}
|
||||
function detailButton(label:string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet button')].find((button)=>button.textContent?.includes(label))!
|
||||
}
|
||||
function json(value: unknown) { return new Response(JSON.stringify(value), { status:200, headers:{ 'content-type':'application/json' } }) }
|
||||
afterEach(()=>{ cleanups.splice(0).forEach((cleanup)=>cleanup()); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
describe('countdown modal accessibility', () => {
|
||||
it('names the dialog and supports focus and Escape close', () => {
|
||||
expect(source).toContain('aria-labelledby="countdown-dialog-title"')
|
||||
it('names the shared dialog contract and delegates focus and Escape handling', () => {
|
||||
expect(source).toContain('title-id="countdown-dialog-title"')
|
||||
expect(source).toContain('id="countdown-dialog-title"')
|
||||
expect(source).toContain('@keydown.esc="closeDialog"')
|
||||
expect(source).toContain('ref="titleInput"')
|
||||
expect(source).toContain('titleInput.value?.focus()')
|
||||
expect(source).toContain('initial-focus="input[aria-label=\'倒数日名称\']"')
|
||||
expect(source).not.toContain('trapDialogFocus')
|
||||
expect(source).not.toContain('ref="titleInput"')
|
||||
expect(source).not.toContain('ref="detailCloseButton"')
|
||||
expect(source).toContain(':inert="open || Boolean(detailItem)"')
|
||||
})
|
||||
|
||||
@@ -21,10 +48,10 @@ describe('countdown modal accessibility', () => {
|
||||
expect(source).toContain('item.id !== focusItem.value?.id')
|
||||
expect(source).toContain('`kind-${item.kind}`')
|
||||
expect(source).toContain(':class="`kind-${focusItem.kind}`"')
|
||||
expect(source).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(source).toContain('ref="detailCloseButton"')
|
||||
expect(source).toContain('detailCloseButton.value?.focus()')
|
||||
expect(source).toContain('@keydown="trapDetailFocus"')
|
||||
expect(source).toContain('panel-class="countdown-detail-sheet"')
|
||||
expect(source).not.toContain('ref="detailCloseButton"')
|
||||
expect(source).toContain('initial-focus="button[aria-label=\'关闭详情\']"')
|
||||
expect(source).not.toContain('trapDetailFocus')
|
||||
expect(source).not.toContain('class="countdown-actions"')
|
||||
})
|
||||
|
||||
@@ -51,8 +78,8 @@ describe('countdown modal accessibility', () => {
|
||||
expect(source).toContain("request('/countdowns?archived=true') as Promise<Countdown[]>")
|
||||
expect(source).toContain('const generation = getCountdownCacheGeneration()')
|
||||
expect(source).toContain('if (!isCountdownCacheGenerationCurrent(generation)) return')
|
||||
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) error.value=')
|
||||
expect(source).toContain('if (isCountdownCacheGenerationCurrent(generation)) busy.value=false')
|
||||
expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=')
|
||||
expect(source).toContain('if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false')
|
||||
})
|
||||
|
||||
it('prevents duplicate submits and sends the edit precondition', () => {
|
||||
@@ -85,4 +112,92 @@ describe('countdown modal accessibility', () => {
|
||||
expect(source).toContain('添加第一个重要日子')
|
||||
expect(source).toContain('@click="openFromEmpty"')
|
||||
})
|
||||
|
||||
it('sends only one pin request on a rapid double click and disables all detail writes', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string, options?: RequestInit) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url} ${options?.method}`)
|
||||
})
|
||||
const { host } = await mountWithFetch(fetchMock)
|
||||
host.querySelector<HTMLButtonElement>('.countdown-focus')!.click(); await nextTick()
|
||||
const pinButton = [...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].find((button)=>button.textContent?.includes('置顶'))!
|
||||
pinButton.click(); pinButton.click(); await nextTick()
|
||||
expect(fetchMock.mock.calls.filter(([url])=>String(url).endsWith('/countdowns/c1/pin'))).toHaveLength(1)
|
||||
expect([...document.querySelectorAll<HTMLButtonElement>('.countdown-detail-sheet footer button')].every((button)=>button.disabled)).toBe(true)
|
||||
pin.resolve(json({})); await flush()
|
||||
})
|
||||
|
||||
it('ignores a stale successful detail write after close and opening another countdown', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
detailButton('置顶').click(); await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
||||
clickCountdown(host, '旅行日'); await nextTick()
|
||||
|
||||
pin.resolve(json({})); await flush()
|
||||
|
||||
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
||||
expect(notices).toEqual([])
|
||||
expect(detailButton('置顶').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a stale failed detail write without polluting the new detail or unlocking its operation', async () => {
|
||||
const oldPin = deferred<Response>()
|
||||
const newPin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return oldPin.promise
|
||||
if (url.endsWith('/countdowns/c2/pin')) return newPin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown, countdownB]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
detailButton('置顶').click(); await nextTick()
|
||||
document.querySelector<HTMLButtonElement>('.countdown-detail-sheet button[aria-label="关闭详情"]')!.click()
|
||||
clickCountdown(host, '旅行日'); await nextTick()
|
||||
detailButton('置顶').click(); await nextTick()
|
||||
|
||||
oldPin.reject(new Error('旧请求失败')); await flush()
|
||||
|
||||
expect(document.querySelector('.countdown-detail-sheet')?.textContent).toContain('旅行日')
|
||||
expect(host.querySelector('.inline-error')?.textContent ?? '').not.toContain('旧请求失败')
|
||||
expect(notices).toEqual([])
|
||||
expect(detailButton('置顶').disabled).toBe(true)
|
||||
newPin.resolve(json({})); await flush()
|
||||
})
|
||||
|
||||
it('keeps the normal current-detail pin flow working', async () => {
|
||||
const pin = deferred<Response>()
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if (url.endsWith('/countdowns/c1/pin')) return pin.promise
|
||||
if (url.endsWith('/countdowns')) return Promise.resolve(json([countdown]))
|
||||
if (url.includes('archived=true')) return Promise.resolve(json([]))
|
||||
throw new Error(`unexpected ${url}`)
|
||||
})
|
||||
const { host, notices } = await mountWithFetch(fetchMock)
|
||||
clickCountdown(host, '发布日'); await nextTick()
|
||||
detailButton('置顶').click(); pin.resolve(json({})); await flush()
|
||||
expect(document.querySelector('.countdown-detail-sheet')).toBeNull()
|
||||
expect(host.querySelector('.countdown-view')?.classList.contains('loading')).toBe(false)
|
||||
expect(notices).toEqual(['已置顶'])
|
||||
})
|
||||
|
||||
it('guards mutation commits and cleanup with the captured detail context', () => {
|
||||
expect(source).toContain('const operationGeneration = ref(0)')
|
||||
expect(source).toContain('const detailGeneration = ref(0)')
|
||||
expect(source).toContain('context.detailGeneration === detailGeneration.value')
|
||||
expect(source).toContain("context.detailId === (detailItem.value?.id ?? null)")
|
||||
expect(source).toContain('if (currentContext(context)) busy.value=false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { Archive, ArchiveRestore, CalendarHeart, ChevronDown, Pencil, Pin, Trash2, X } from 'lucide-vue-next'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
|
||||
type Countdown = {
|
||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||
@@ -17,34 +19,21 @@ const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
|
||||
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
||||
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
|
||||
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
|
||||
const operationGeneration = ref(0)
|
||||
const detailGeneration = ref(0)
|
||||
type OperationContext = { generation:number; detailId:string|null; detailGeneration:number }
|
||||
let activeOperation:OperationContext|null = null
|
||||
let mounted = true
|
||||
const currentYear = new Date().getFullYear()
|
||||
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
||||
const form = ref<Form>(freshForm())
|
||||
const composerOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||
const composerStyle = computed(() => ({ '--fab-origin-x': `${composerOrigin.value.x}px`, '--fab-origin-y': `${composerOrigin.value.y}px` }))
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
const detailCloseButton = ref<HTMLButtonElement | null>(null)
|
||||
let previousFocus: HTMLElement | null = null
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
|
||||
function focusDialog() {
|
||||
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
void nextTick(() => titleInput.value?.focus())
|
||||
}
|
||||
function closeDialog() {
|
||||
open.value = false
|
||||
showAdvanced.value = false
|
||||
void nextTick(() => previousFocus?.focus())
|
||||
}
|
||||
function trapDialogFocus(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') return
|
||||
const dialog = event.currentTarget as HTMLElement
|
||||
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||
.filter((item) => !item.hasAttribute('disabled'))
|
||||
if (!controls.length) return
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
|
||||
function primaryDate(item: Countdown) {
|
||||
@@ -99,7 +88,25 @@ async function request(path:string, options:RequestInit={}) {
|
||||
}
|
||||
return response.status === 204 ? null : response.json()
|
||||
}
|
||||
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
|
||||
function currentContext(context:OperationContext) {
|
||||
return mounted && context.generation === operationGeneration.value && context.detailGeneration === detailGeneration.value && context.detailId === (detailItem.value?.id ?? null)
|
||||
}
|
||||
async function safe(detailId:string|null, work:(context:OperationContext)=>Promise<void>) {
|
||||
if (activeOperation && currentContext(activeOperation)) return
|
||||
const context={ generation:++operationGeneration.value, detailId, detailGeneration:detailGeneration.value }
|
||||
activeOperation=context
|
||||
busy.value=true
|
||||
error.value=''
|
||||
try {
|
||||
await work(context)
|
||||
} catch(reason) {
|
||||
if (!currentContext(context)) return
|
||||
error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||
} finally {
|
||||
if (activeOperation === context) activeOperation=null
|
||||
if (currentContext(context)) busy.value=false
|
||||
}
|
||||
}
|
||||
async function fetchCountdowns() {
|
||||
const [active, archivedItems] = await Promise.all([
|
||||
request('/countdowns') as Promise<Countdown[]>,
|
||||
@@ -107,31 +114,36 @@ async function fetchCountdowns() {
|
||||
])
|
||||
return { items: active, archived: archivedItems }
|
||||
}
|
||||
async function load(force = false) {
|
||||
async function load(force = false, manageBusy = true) {
|
||||
const generation = getCountdownCacheGeneration()
|
||||
const cached = readCountdownCache<Countdown>()
|
||||
if (cached) { items.value=cached.items; archived.value=cached.archived }
|
||||
if (!cached) busy.value=true
|
||||
error.value=''
|
||||
if (!cached && manageBusy) busy.value=true
|
||||
if (manageBusy) error.value=''
|
||||
try {
|
||||
const data = await loadCountdownCache(fetchCountdowns, { force })
|
||||
if (!isCountdownCacheGenerationCurrent(generation)) return
|
||||
items.value=data.items
|
||||
archived.value=data.archived
|
||||
} catch(reason) {
|
||||
if (isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败'
|
||||
} finally {
|
||||
if (isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
|
||||
}
|
||||
}
|
||||
function selectDetail(item:Countdown|null) {
|
||||
detailGeneration.value += 1
|
||||
detailItem.value=item
|
||||
error.value=''
|
||||
if (!activeOperation || !currentContext(activeOperation)) busy.value=false
|
||||
}
|
||||
function edit(item:Countdown) {
|
||||
detailItem.value=null
|
||||
selectDetail(null)
|
||||
editingId.value=item.id
|
||||
editingItem.value=item
|
||||
showAdvanced.value=false
|
||||
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
||||
open.value=true
|
||||
focusDialog()
|
||||
}
|
||||
function applyKindDefaults() {
|
||||
if (form.value.kind === 'birthday' || form.value.kind === 'anniversary') form.value.repeat_rule='yearly'
|
||||
@@ -140,40 +152,35 @@ function applyKindDefaults() {
|
||||
async function save() {
|
||||
if (busy.value) return
|
||||
if (!form.value.title.trim()) return
|
||||
await safe(async()=>{
|
||||
await safe(null, async(context)=>{
|
||||
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
||||
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
|
||||
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
||||
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
||||
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
||||
invalidateCountdownCache(); closeDialog(); await load(true); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||
if (!currentContext(context)) return
|
||||
invalidateCountdownCache(); closeDialog(); await load(true, false)
|
||||
if (!currentContext(context)) return
|
||||
emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
|
||||
})
|
||||
}
|
||||
async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已置顶')})}
|
||||
async function archiveItem(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();detailItem.value=null;await load(true);emit('notice','已归档')})}
|
||||
async function restore(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true);emit('notice','已恢复')})}
|
||||
async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}”?这个操作不能撤销。`))return;await safe(async()=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true);emit('notice','已永久删除')})}
|
||||
async function pin(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已置顶');closeDetail();await load(true,false)})}
|
||||
async function archiveItem(item:Countdown){await safe(item.id,async(context)=>{await request(`/countdowns/${item.id}`,{method:'DELETE'});invalidateCountdownCache();if(!currentContext(context)){void load(true,false);return}emit('notice','已归档');closeDetail();await load(true,false)})}
|
||||
async function restore(item:Countdown){await safe(null,async(context)=>{await request(`/countdowns/${item.id}/restore`,{method:'POST'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已恢复')})}
|
||||
async function purge(item:Countdown){if(busy.value)return;if(await appDialog.value?.show({title:`永久删除“${item.title}”?`,description:'这个操作不能撤销。',danger:true,confirmText:'永久删除'})!==true)return;if(busy.value)return;await safe(null,async(context)=>{await request(`/countdowns/${item.id}/purge`,{method:'DELETE'});invalidateCountdownCache();await load(true,false);if(!currentContext(context))return;emit('notice','已永久删除')})}
|
||||
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}年${Number(m)}月${Number(d)}日`}
|
||||
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
|
||||
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
|
||||
function openDetail(item:Countdown){detailItem.value=item;previousFocus=document.activeElement instanceof HTMLElement ? document.activeElement : null;void nextTick(() => detailCloseButton.value?.focus())}
|
||||
function closeDetail(){detailItem.value=null;void nextTick(() => previousFocus?.focus())}
|
||||
function trapDetailFocus(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') return
|
||||
const dialog = event.currentTarget as HTMLElement
|
||||
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||
.filter((item) => !item.hasAttribute('disabled'))
|
||||
if (!controls.length) return
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
function openDetail(item:Countdown){selectDetail(item)}
|
||||
function closeDetail(){selectDetail(null)}
|
||||
function openFromEmpty(){openCountdownComposer()}
|
||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;selectDetail(null);editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true}
|
||||
defineExpose({ openCountdownComposer })
|
||||
onMounted(() => { void load() })
|
||||
onBeforeUnmount(() => { previousFocus = null })
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false
|
||||
operationGeneration.value += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -199,21 +206,20 @@ onBeforeUnmount(() => { previousFocus = null })
|
||||
</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>
|
||||
<button v-if="archived.length" class="archived-toggle" @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 @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
|
||||
<button v-if="archived.length" class="archived-toggle" :disabled="busy" @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>
|
||||
<Transition name="countdown-detail">
|
||||
<div v-if="detailItem" class="countdown-detail-mask app-sheet-mask" @click.self="closeDetail"><article class="countdown-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="countdown-detail-title" @keydown.esc="closeDetail" @keydown="trapDetailFocus">
|
||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button ref="detailCloseButton" type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||
<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">
|
||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
||||
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
||||
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||
</article></div>
|
||||
</Transition>
|
||||
<Transition name="countdown-compose">
|
||||
<div v-if="open" class="countdown-modal-mask app-sheet-mask" @click.self="closeDialog"><form class="countdown-modal app-sheet app-sheet--create" :style="composerStyle" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus">
|
||||
<header class="app-sheet__header"><div><small>{{editingId?'调整重要日子':'快速记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
|
||||
<div class="app-sheet__body"><label>名称<input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></label>
|
||||
<footer class="app-sheet__footer"><button v-if="!detailItem.pinned" type="button" :disabled="busy" @click="pin(detailItem)"><Pin/>置顶</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
<AppSheet :open="open" variant="create" panel-class="countdown-modal" title-id="countdown-dialog-title" initial-focus="input[aria-label='倒数日名称']" :busy="busy" :style="composerStyle" @close="closeDialog" @submit.prevent="save">
|
||||
<header class="app-sheet__header"><div><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
|
||||
<div class="app-sheet__body"><label>名称<input v-model="form.title" aria-label="倒数日名称" maxlength="200" required placeholder="例如:去北海道旅行"></label>
|
||||
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
|
||||
<label>类型<select v-model="form.kind" @change="applyKindDefaults"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
|
||||
<details class="countdown-advanced" :open="showAdvanced" @toggle="showAdvanced=($event.target as HTMLDetailsElement).open"><summary><span>更多设置</span><ChevronDown/></summary>
|
||||
@@ -228,7 +234,7 @@ onBeforeUnmount(() => { previousFocus = null })
|
||||
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
||||
</details></div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
|
||||
</form></div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('memo shell integration', () => {
|
||||
expect(app).toContain("import MemoPanel from './MemoPanel.vue'")
|
||||
expect(panel).toContain("import MemoRow")
|
||||
expect(panel).toContain("import MemoEditor")
|
||||
expect(app).toContain('<span>任务详情</span>')
|
||||
expect(app).toContain('<span id="task-detail-title">任务详情</span>')
|
||||
expect(app).toContain('<div class="field-label"><span>任务备注</span>')
|
||||
})
|
||||
|
||||
@@ -46,6 +46,16 @@ describe('memo shell integration', () => {
|
||||
expect(css).toContain('.memo-markdown-preview{min-height:250px')
|
||||
})
|
||||
|
||||
it('uses AppSheet for mobile memo detail while keeping desktop detail non-modal', () => {
|
||||
expect(panel).toContain("import AppSheet from './components/AppSheet.vue'")
|
||||
expect(panel).toContain('<AppSheet :open="Boolean(selected)" :modal="mobileDetail"')
|
||||
expect(panel).toContain('panel-class="memo-editor"')
|
||||
expect(panel).toContain('title-id="memo-editor-title"')
|
||||
expect(panel).not.toContain('memo-editor-scrim')
|
||||
expect(editor).not.toContain('aria-modal')
|
||||
expect(editor).not.toContain("event.key !== 'Tab'")
|
||||
})
|
||||
|
||||
it('tracks editor state in the shell, reserves desktop space, hides the FAB, and marks mobile background regions inert', () => {
|
||||
expect(app).toContain('const memoDetailOpen = ref(false)')
|
||||
expect(app).toContain("'memo-detail-open': activeView==='memos' && memoDetailOpen")
|
||||
@@ -68,6 +78,5 @@ describe('memo shell integration', () => {
|
||||
expect(css).toContain('height:min(92dvh,820px)')
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.memo-editor')
|
||||
expect(editor).toContain("window.addEventListener('beforeunload'")
|
||||
expect(editor).toContain("event.key !== 'Tab'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,12 @@ function deferred<T>() {
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||
async function answerDialog(confirm: boolean) {
|
||||
await nextTick()
|
||||
const selector = confirm ? '.app-dialog button[type="submit"]' : '.app-dialog .secondary'
|
||||
document.querySelector<HTMLButtonElement>(selector)!.click()
|
||||
await flush()
|
||||
}
|
||||
|
||||
async function mount(request: RequestMock, onNotice?: (message: string) => void, onDetail?: (open: boolean) => void) {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
@@ -85,7 +91,6 @@ describe('MemoPanel', () => {
|
||||
['success', null],
|
||||
['error', new Error('迟到保存失败')],
|
||||
])('keeps detail closed after a pending save closes and settles with %s', async (_case, failure) => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const pending = deferred<unknown>()
|
||||
const notices: string[] = []
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => options?.method === 'PATCH'
|
||||
@@ -96,7 +101,7 @@ describe('MemoPanel', () => {
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||
if (failure) pending.reject(failure)
|
||||
else pending.resolve({ ...item, title: '待保存', content: '正文', version: 2 })
|
||||
await flush()
|
||||
@@ -119,8 +124,7 @@ describe('MemoPanel', () => {
|
||||
title.value = '冲突'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(true)
|
||||
reload.resolve({ ...item, title: '迟到重载', content: '正文' }); await flush()
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
})
|
||||
@@ -130,7 +134,6 @@ describe('MemoPanel', () => {
|
||||
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||
])('emits the current %s notice through the panel before closing detail', async (_name, deletedAt, selector, message) => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const scoped = { ...item, deleted_at: deletedAt }
|
||||
const notices: string[] = []
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
@@ -141,13 +144,14 @@ describe('MemoPanel', () => {
|
||||
const { host } = await mount(request, (message) => notices.push(message))
|
||||
if (deletedAt) { host.querySelector<HTMLButtonElement>('[data-scope="trash"]')!.click(); await flush() }
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>(selector)!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>(selector)!.click()
|
||||
if (_name !== 'restore') await answerDialog(true)
|
||||
await flush()
|
||||
expect(notices).toEqual([message])
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not move focus or close detail when dirty close is cancelled', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const details: boolean[] = []
|
||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||
? { ...item, content: '正文' }
|
||||
@@ -157,7 +161,7 @@ describe('MemoPanel', () => {
|
||||
row.click(); await flush()
|
||||
const content = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
content.value = '未保存'; content.dispatchEvent(new Event('input')); content.focus(); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await answerDialog(false)
|
||||
expect(host.querySelector('.memo-editor')).not.toBeNull()
|
||||
expect(details).toEqual([true])
|
||||
expect(document.activeElement).toBe(content)
|
||||
@@ -174,7 +178,7 @@ describe('MemoPanel', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 })
|
||||
window.dispatchEvent(new Event('resize')); await nextTick()
|
||||
expect(desktop.host.querySelector('.memo-panel__main')?.hasAttribute('inert')).toBe(true)
|
||||
expect(desktop.host.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
||||
expect(document.querySelector('.memo-editor')?.getAttribute('aria-modal')).toBe('true')
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||
})
|
||||
|
||||
@@ -247,8 +251,8 @@ describe('MemoPanel', () => {
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click()
|
||||
if (lifecycle === 'delete') {
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
await answerDialog(true)
|
||||
} else host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||
await flush()
|
||||
pending.resolve({ ...scopedItem, title: '不应重新打开', content: '迟到详情' }); await flush()
|
||||
@@ -510,8 +514,7 @@ describe('MemoPanel', () => {
|
||||
title.value = 'A 已保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||
|
||||
save.resolve({ ...item, title: 'A 已保存', content: 'A 正文', version: 2 })
|
||||
@@ -539,8 +542,7 @@ describe('MemoPanel', () => {
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
@@ -574,8 +576,7 @@ describe('MemoPanel', () => {
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
@@ -613,8 +614,7 @@ describe('MemoPanel', () => {
|
||||
firstTitle.value = 'A 旧保存'; firstTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValueOnce(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
const reopenedTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
reopenedTitle.value = 'A 新草稿'; reopenedTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
@@ -742,7 +742,6 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
|
||||
it('keeps memo B open when memo A deletion finishes late and removes only A from its committed list', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const remove = deferred<unknown>()
|
||||
const other = { ...item, id: 'm2', title: '第二条' }
|
||||
const request = vi.fn((path: string, options?: RequestInit): Promise<unknown> => {
|
||||
@@ -754,6 +753,7 @@ describe('MemoPanel', () => {
|
||||
const { host } = await mount(request)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[0].click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
await answerDialog(true)
|
||||
host.querySelectorAll<HTMLButtonElement>('.memo-row')[1].click(); await flush()
|
||||
remove.resolve(undefined); await flush()
|
||||
|
||||
@@ -762,7 +762,6 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
|
||||
it('falls back to search after deletion removes the opening row during nextTick', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const request = vi.fn(async (path: string, options?: RequestInit): Promise<unknown> => {
|
||||
if (path === '/memos/m1' && options?.method === 'DELETE') return undefined
|
||||
if (path === '/memos/m1') return { ...item, content: '正文' }
|
||||
@@ -770,7 +769,7 @@ describe('MemoPanel', () => {
|
||||
})
|
||||
const { host } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await answerDialog(true)
|
||||
expect(document.activeElement).toBe(host.querySelector('[aria-label="搜索备忘录"]'))
|
||||
})
|
||||
|
||||
@@ -791,6 +790,26 @@ describe('MemoPanel', () => {
|
||||
expect(host.querySelector('.memo-editor')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses AppDialog for dirty draft creation and honors cancel then confirm', async () => {
|
||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||
? { ...item, content: '正文' }
|
||||
: { items: [item], total: 1 })
|
||||
const nativeConfirm = vi.spyOn(window, 'confirm')
|
||||
const { host, vm } = await mount(request)
|
||||
host.querySelector<HTMLButtonElement>('.memo-row')!.click(); await flush()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '未保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
|
||||
void vm.createMemo(); await nextTick()
|
||||
expect(document.querySelector('.app-dialog')?.textContent).toContain('放弃未保存的更改')
|
||||
await answerDialog(false)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('未保存')
|
||||
|
||||
void vm.createMemo(); await nextTick(); await answerDialog(true)
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('')
|
||||
expect(nativeConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('switches between active and trash and opens a local draft through the exposed FAB action', async () => {
|
||||
const request = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ items: [], total: 0 }))
|
||||
const { host, vm } = await mount(request)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Archive, FileText, Search } from 'lucide-vue-next'
|
||||
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
|
||||
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ request: RequestFn }>()
|
||||
@@ -18,6 +20,7 @@ const error = ref('')
|
||||
const selected = ref<MemoEditorValue | null>(null)
|
||||
const selectedToken = ref(0)
|
||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const mobileDetail = ref(window.innerWidth <= 930)
|
||||
let detailOpener: HTMLElement | null = null
|
||||
@@ -87,13 +90,20 @@ function closeDetail() {
|
||||
detailOpener = null
|
||||
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
|
||||
}
|
||||
function showConfirm(options: AppDialogOptions) {
|
||||
return appDialog.value?.show(options).then((result) => result === true) ?? Promise.resolve(false)
|
||||
}
|
||||
function confirmDiscard(description: string) {
|
||||
if (!editor.value?.dirty) return Promise.resolve(true)
|
||||
return showConfirm({ title: '放弃未保存的更改?', description, danger: true, confirmText: '放弃更改' })
|
||||
}
|
||||
async function setScope(next: 'active' | 'trash') {
|
||||
if (next === scope.value) return
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
||||
closeDetail(); scope.value = next; emit('scope', next); await load()
|
||||
}
|
||||
async function selectMemo(id: string, opener?: EventTarget | null) {
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
|
||||
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
||||
if (opener instanceof HTMLElement) detailOpener = opener
|
||||
const token = ++detailGeneration
|
||||
try {
|
||||
@@ -105,7 +115,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
|
||||
}
|
||||
async function createMemo() {
|
||||
if (scope.value === 'trash') return
|
||||
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
|
||||
if (editor.value?.dirty && !(await confirmDiscard('新建后,当前草稿不会保存。'))) return
|
||||
const token = ++detailGeneration
|
||||
selectedToken.value = token
|
||||
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||
@@ -195,13 +205,15 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
|
||||
</div>
|
||||
<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-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角团子猫新建一条'}}</span></div>
|
||||
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
|
||||
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
|
||||
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
|
||||
</div>
|
||||
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载…':'加载更多'}}</button>
|
||||
</div>
|
||||
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
|
||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||
<AppSheet :open="Boolean(selected)" :modal="mobileDetail" variant="detail" panel-class="memo-editor" title-id="memo-editor-title" initial-focus="input[aria-label='备忘录标题']" :close-on-scrim="mobileDetail" @close="editor?.requestClose()">
|
||||
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" :confirm-action="showConfirm" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+114
-50
@@ -1,10 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { ArchiveRestore, Check, ChevronRight, Download, GripVertical, Pencil, Trash2, X } from 'lucide-vue-next'
|
||||
import { downloadFullBackup, preflightBackup, restoreBackup, uploadJson, requestJson, type BackupMode, type BackupPreflight } from './api'
|
||||
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
|
||||
import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldCommitBackupPreflight, type BackupFileSnapshot } from './lib/backup-preflight-state'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
|
||||
type View = 'habits' | 'today-habits' | 'settings'
|
||||
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
|
||||
@@ -51,12 +55,30 @@ const habitHistoryNextTo = ref('')
|
||||
const habitHistoryHasMore = ref(false)
|
||||
let habitHistoryRequest = 0
|
||||
const habitDetailClickSuppressed = ref(false)
|
||||
const habitDetailSheet = ref<HTMLElement | null>(null)
|
||||
let habitDetailOpener: HTMLElement | null = null
|
||||
const habitComposeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||||
const habitComposeStyle = computed(() => ({ '--fab-origin-x': `${habitComposeOrigin.value.x}px`, '--fab-origin-y': `${habitComposeOrigin.value.y}px` }))
|
||||
const habitNameInput = ref<HTMLInputElement | null>(null)
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
async function confirmAction(title: string, description?: string) {
|
||||
return await appDialog.value?.show({ title, description, danger: true, confirmText: '确认' }) === true
|
||||
}
|
||||
const restoreFile = ref<File | null>(null)
|
||||
const restoreMode = ref<BackupMode>('merge')
|
||||
const restorePreflight = ref<BackupPreflight | null>(null)
|
||||
const backupBusy = ref(false)
|
||||
const backupError = ref('')
|
||||
const restoreInput = ref<HTMLInputElement | null>(null)
|
||||
let preflightGeneration = 0
|
||||
let preflightController: AbortController | null = null
|
||||
let acceptedPreflightSnapshot: BackupFileSnapshot | null = null
|
||||
const legacyRestore = computed(() => Boolean(restoreFile.value && isLegacyBackup(restoreFile.value)))
|
||||
function cancelPreflight() {
|
||||
preflightGeneration += 1
|
||||
preflightController?.abort()
|
||||
preflightController = null
|
||||
backupBusy.value = false
|
||||
}
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
@@ -101,18 +123,16 @@ watch(habitReorderAvailable, (available) => {
|
||||
})
|
||||
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
async function request(path: string, options: RequestInit = {}) {
|
||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string> || {}) }
|
||||
if (options.body && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
|
||||
const csrf = csrfHeader(options.method)
|
||||
if (csrf['x-csrf-token']) headers['x-csrf-token'] = csrf['x-csrf-token']
|
||||
const response = await fetch('/api/v1' + path, { credentials: 'include', ...options, headers })
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
throw new Error(formatHabitApiError((body as { detail?: unknown }).detail))
|
||||
async function request<T = unknown>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
if (options.body instanceof FormData) {
|
||||
const file = options.body.get('file')
|
||||
if (file instanceof File) return uploadJson<T>(path, file, options)
|
||||
}
|
||||
const type = response.headers.get('content-type') || ''
|
||||
return response.status === 204 ? null : type.includes('json') ? response.json() : response.blob()
|
||||
let body: unknown = undefined
|
||||
if (typeof options.body === 'string') {
|
||||
try { body = JSON.parse(options.body) } catch { body = options.body }
|
||||
}
|
||||
return requestJson<T>(path, { ...options, body })
|
||||
}
|
||||
async function safe(work: () => Promise<void>) {
|
||||
busy.value = true; error.value = ''
|
||||
@@ -485,7 +505,6 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
|
||||
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
|
||||
selectedHabit.value = h
|
||||
void loadHabitHistory(true)
|
||||
void nextTick(() => habitDetailSheet.value?.focus())
|
||||
}
|
||||
function closeHabitDetail() {
|
||||
habitHistoryRequest += 1
|
||||
@@ -503,7 +522,7 @@ function closeHabitDetail() {
|
||||
}
|
||||
defineExpose({ openHabitComposer, refreshHabits: loadHabits })
|
||||
async function archiveHabit(h: Habit) {
|
||||
if (!confirm(`归档习惯“${h.name}”?历史打卡记录会保留。`)) return
|
||||
if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
|
||||
await safe(async () => {
|
||||
await request(`/habits/${h.id}`, { method: 'DELETE' })
|
||||
selectedHabit.value = null
|
||||
@@ -532,7 +551,7 @@ async function restoreHabit(h: Habit) {
|
||||
}
|
||||
}
|
||||
async function deleteHabit(h: Habit) {
|
||||
if (!h.archived_at || !confirm(`永久删除习惯“${h.name}”?所有历史打卡记录也会被删除,且无法恢复。`)) return
|
||||
if (!h.archived_at || !(await confirmAction(`永久删除习惯“${h.name}”?`, '所有历史打卡记录也会被删除,且无法恢复。'))) return
|
||||
error.value = ''
|
||||
try {
|
||||
await request(`/habits/${h.id}/permanent`, { method: 'DELETE' })
|
||||
@@ -587,7 +606,7 @@ async function loadHabits() {
|
||||
}
|
||||
async function loadSettings() {
|
||||
await safe(async () => {
|
||||
const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])])
|
||||
const [s, a] = await Promise.all([request<Session[] | { items?: Session[] }>('/sessions').catch(() => []), request<any[] | { items?: any[] }>('/audit-logs?limit=20').catch(() => [])])
|
||||
sessions.value = mergePage<Session>(s).items
|
||||
audit.value = mergePage<any>(a).items
|
||||
})
|
||||
@@ -596,7 +615,7 @@ async function revoke(id: string) {
|
||||
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
|
||||
}
|
||||
async function revokeOtherSessions() {
|
||||
if (!confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')) return
|
||||
if (!(await confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。'))) return
|
||||
await safe(async () => {
|
||||
await request('/sessions/others', { method: 'DELETE' })
|
||||
await loadSettings()
|
||||
@@ -607,26 +626,74 @@ function downloadBlob(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
async function exportData() {
|
||||
await safe(async () => {
|
||||
const response = await fetch('/api/v1/export.csv', { credentials: 'include' })
|
||||
if (!response.ok) throw new Error('导出失败')
|
||||
downloadBlob(await response.blob(), 'dodo-export.csv')
|
||||
})
|
||||
backupBusy.value = true; backupError.value = ''
|
||||
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
|
||||
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' }
|
||||
finally { backupBusy.value = false }
|
||||
}
|
||||
function selectRestoreFile(event: Event) {
|
||||
cancelPreflight()
|
||||
restoreFile.value = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||
if (restoreFile.value && isLegacyBackup(restoreFile.value)) restoreMode.value = 'merge'
|
||||
restorePreflight.value = null
|
||||
acceptedPreflightSnapshot = null
|
||||
backupError.value = ''
|
||||
}
|
||||
watch(restoreMode, () => {
|
||||
cancelPreflight()
|
||||
restorePreflight.value = null
|
||||
acceptedPreflightSnapshot = null
|
||||
backupError.value = ''
|
||||
})
|
||||
const backupEntityTotal = computed(() => Object.values(restorePreflight.value?.entities ?? {}).reduce((sum, count) => sum + count, 0))
|
||||
async function runPreflight() {
|
||||
const file = restoreFile.value
|
||||
if (!file || isLegacyBackup(file)) return
|
||||
cancelPreflight()
|
||||
const generation = preflightGeneration
|
||||
const snapshot = backupFileSnapshot(file, restoreMode.value)
|
||||
const controller = new AbortController()
|
||||
preflightController = controller
|
||||
backupBusy.value = true; backupError.value = ''; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||
try {
|
||||
const result = await preflightBackup(file, snapshot.mode, controller.signal)
|
||||
if (!shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)) return
|
||||
restorePreflight.value = result
|
||||
acceptedPreflightSnapshot = snapshot
|
||||
} catch (reason) {
|
||||
if (generation !== preflightGeneration || controller.signal.aborted) return
|
||||
backupError.value = reason instanceof Error ? reason.message : '备份预检失败'
|
||||
} finally {
|
||||
if (generation === preflightGeneration) {
|
||||
backupBusy.value = false
|
||||
preflightController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
async function restore() {
|
||||
if (!restoreFile.value) return
|
||||
if (!confirm('恢复为合并模式,将导入备份中的清单与任务。继续吗?')) return
|
||||
await safe(async () => {
|
||||
if (restoreFile.value!.name.toLowerCase().endsWith('.csv')) {
|
||||
const form = new FormData()
|
||||
form.append('file', restoreFile.value!)
|
||||
await request('/restore.csv?mode=merge', { method: 'POST', body: form })
|
||||
const file = restoreFile.value
|
||||
const mode = restoreMode.value
|
||||
const preview = restorePreflight.value
|
||||
const legacy = Boolean(file && isLegacyBackup(file))
|
||||
if (!file || mode !== restoreMode.value || (legacy ? mode !== 'merge' : !preview?.valid || !acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode))) return
|
||||
const destructive = legacy ? '旧格式将在恢复时由服务端校验,仅支持合并恢复。' : mode === 'replace' ? '现有数据将被备份内容替换,此操作不可撤销。' : '同名或相同标识的数据将按合并规则处理。'
|
||||
if (!(await confirmAction(mode === 'replace' ? '确认替换全部数据?' : '确认合并备份?', destructive))) return
|
||||
if (file !== restoreFile.value || mode !== restoreMode.value || (!legacy && (!acceptedPreflightSnapshot || !isCurrentBackupSnapshot(acceptedPreflightSnapshot, file, mode)))) return
|
||||
backupBusy.value = true; backupError.value = ''
|
||||
try {
|
||||
if (!legacy) {
|
||||
if (!preview?.preflight_token) throw new Error('预检令牌无效,请重新预检')
|
||||
await restoreBackup(preview.preflight_token, mode)
|
||||
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||
await uploadJson('/restore.csv?mode=merge', file)
|
||||
} else {
|
||||
const text = await restoreFile.value!.text()
|
||||
await request('/restore?mode=merge', { method: 'POST', body: text })
|
||||
await requestJson('/restore?mode=merge', { method: 'POST', body: JSON.parse(await file.text()) })
|
||||
}
|
||||
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
|
||||
if (restoreInput.value) restoreInput.value.value = ''
|
||||
emit('changed'); emit('notice', '数据已恢复')
|
||||
})
|
||||
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||
finally { backupBusy.value = false }
|
||||
}
|
||||
async function changePassword() {
|
||||
passwordError.value = ''
|
||||
@@ -665,6 +732,7 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
cancelPreflight()
|
||||
if (dayRolloverTimer) clearInterval(dayRolloverTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -694,9 +762,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- 完整习惯列表 -->
|
||||
<Transition name="task-compose">
|
||||
<div v-if="habitComposerOpen" class="task-compose-mask app-sheet-mask" @click.self="closeHabitComposer">
|
||||
<form class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create" :style="habitComposeStyle" role="dialog" aria-modal="true" aria-labelledby="habit-compose-title" @submit.prevent="saveHabit" @keydown.esc="closeHabitComposer">
|
||||
<AppSheet :open="habitComposerOpen" variant="create" panel-class="task-compose-sheet habit-compose-sheet" title-id="habit-compose-title" initial-focus="input[aria-label='新习惯名称']" :busy="busy" :style="habitComposeStyle" @close="closeHabitComposer" @submit.prevent="saveHabit">
|
||||
<header class="app-sheet__header"><div><h2 id="habit-compose-title">{{ habitComposerTitle }}</h2></div><button class="icon" type="button" :aria-label="`关闭${habitComposerTitle}`" @click="closeHabitComposer"><X /></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<p v-if="habitFormError" class="inline-error" role="alert" tabindex="-1">{{ habitFormError }}</p>
|
||||
@@ -708,9 +774,7 @@ onBeforeUnmount(() => {
|
||||
<label v-if="habitSchedule === 'interval'">间隔天数<input v-model.number="habitIntervalDays" type="number" min="1" step="1" :aria-invalid="Boolean(habitErrors.interval_days)" aria-describedby="habit-interval-error"><small v-if="habitErrors.interval_days" id="habit-interval-error" class="field-error" role="alert">{{ habitErrors.interval_days }}</small></label>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><span v-if="habitFormInvalid" class="field-error" role="status">请修正表单中的错误后再保存</span><button type="button" class="secondary" @click="closeHabitComposer">取消</button><button class="primary-small" :disabled="busy || habitFormInvalid">{{ busy ? '保存中…' : editingHabit ? '保存修改' : '添加习惯' }}</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
|
||||
|
||||
<div v-if="habitReorderAvailable" class="habit-reorder-toolbar"><button class="soft-button reorder-mode-toggle habit-reorder-toggle" type="button" :aria-pressed="habitReorderMode" @click="habitReorderMode=!habitReorderMode;cancelHabitReorder()">{{ habitReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||||
@@ -742,9 +806,8 @@ onBeforeUnmount(() => {
|
||||
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
|
||||
</div>
|
||||
</section>
|
||||
<Transition name="countdown-detail">
|
||||
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
|
||||
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
|
||||
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
|
||||
<template v-if="selectedHabit">
|
||||
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||||
@@ -767,9 +830,8 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
|
||||
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
</AppSheet>
|
||||
</template>
|
||||
|
||||
<!-- 设置与数据 -->
|
||||
@@ -777,12 +839,14 @@ onBeforeUnmount(() => {
|
||||
<header class="view-intro">
|
||||
<div><small>备份、迁移与安全</small></div>
|
||||
</header>
|
||||
<div class="settings-grid">
|
||||
<article class="tool-card"><FileJson /><h2>数据导出与恢复</h2><p>导出完整 CSV 数据,或从 CSV / JSON 备份恢复。</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
|
||||
<article class="tool-card password-card"><Activity /><h2>修改密码</h2><p>修改后当前设备保持登录,其他设备会自动退出。</p><form class="password-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></article>
|
||||
<article class="tool-card wide"><LogOut /><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><div v-for="s in sessions" :key="s.id" class="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">没有可显示的会话。</p></article>
|
||||
<article v-if="audit.length" class="tool-card wide"><Activity /><h2>最近活动</h2><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><div class="audit-copy"><span class="audit-action" :title="row.action ?? row.event ?? undefined">{{ 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></article>
|
||||
<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"><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><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>
|
||||
</template>
|
||||
<AppDialog ref="appDialog" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, requestBlob, requestJson, uploadJson } from './http'
|
||||
import { downloadFullBackup, preflightBackup, restoreBackup } from './backups'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
function response(body: BodyInit | null, init: ResponseInit = {}) {
|
||||
return new Response(body, { status: 200, ...init })
|
||||
}
|
||||
|
||||
describe('typed HTTP client', () => {
|
||||
it('sends credentials, CSRF, JSON and AbortSignal consistently', async () => {
|
||||
document.cookie = 'dodo_csrf=csrf-token'
|
||||
const signal = new AbortController().signal
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(response('{"ok":true}', { headers: { 'content-type': 'application/json' } }))
|
||||
|
||||
await requestJson<{ ok: boolean }>('/example', { method: 'POST', body: { value: 1 }, signal })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v1/example', expect.objectContaining({ credentials: 'include', signal, method: 'POST', body: '{"value":1}' }))
|
||||
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers)
|
||||
expect(headers.get('content-type')).toBe('application/json')
|
||||
expect(headers.get('x-csrf-token')).toBe('csrf-token')
|
||||
})
|
||||
|
||||
it('normalizes FastAPI validation details without losing the status or code', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(response(JSON.stringify({ detail: [{ loc: ['body', 'name'], msg: '必填', type: 'missing' }], code: 'invalid_backup' }), { status: 422, headers: { 'content-type': 'application/json' } }))
|
||||
|
||||
const error = await requestJson('/bad').catch((reason) => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
const apiError = error as ApiError
|
||||
expect(apiError).toMatchObject({ status: 422, code: 'invalid_backup' })
|
||||
expect(apiError.message).toContain('name')
|
||||
expect(apiError.message).toContain('必填')
|
||||
})
|
||||
|
||||
it('keeps blob and multipart requests typed without forcing JSON content type', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } }))
|
||||
.mockResolvedValueOnce(response('{"valid":true}', { headers: { 'content-type': 'application/json' } }))
|
||||
const blob = await requestBlob('/backup/export.zip')
|
||||
expect(blob.size).toBe(8)
|
||||
expect(blob.type).toBe('application/zip')
|
||||
await uploadJson('/backup/preflight?mode=merge', new File(['zip'], 'backup.zip'))
|
||||
const uploadHeaders = new Headers(fetchMock.mock.calls[1][1]?.headers)
|
||||
expect(uploadHeaders.has('content-type')).toBe(false)
|
||||
expect(fetchMock.mock.calls[1][1]?.body).toBeInstanceOf(FormData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('backup API', () => {
|
||||
it('uses the versioned ZIP export, preflight and restore endpoints', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce(response('zip-data', { headers: { 'content-type': 'application/zip' } }))
|
||||
.mockResolvedValueOnce(response(JSON.stringify({ valid: true, preflight_token: 'token', backup_id: 'backup', archive_sha256: 'sha', entities: { tasks: 2, attachments: 1 } }), { headers: { 'content-type': 'application/json' } }))
|
||||
.mockResolvedValueOnce(response(JSON.stringify({ restored: 3, mode: 'replace' }), { headers: { 'content-type': 'application/json' } }))
|
||||
|
||||
await downloadFullBackup()
|
||||
const preview = await preflightBackup(new File(['zip'], 'dodo.zip'), 'replace')
|
||||
await restoreBackup(preview.preflight_token!, 'replace')
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/v1/backup/export.zip',
|
||||
'/api/v1/backup/preflight?mode=replace',
|
||||
'/api/v1/backup/restore',
|
||||
])
|
||||
expect(preview.entities.tasks).toBe(2)
|
||||
expect(fetchMock.mock.calls[2][1]?.body).toBe(JSON.stringify({ preflight_token: 'token', mode: 'replace' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { requestBlob, requestJson, uploadJson } from './http'
|
||||
|
||||
export type BackupMode = 'merge' | 'replace'
|
||||
export type BackupPreflight = {
|
||||
valid: boolean
|
||||
preflight_token?: string
|
||||
backup_id?: string
|
||||
archive_sha256?: string
|
||||
version?: number
|
||||
entities: Record<string, number>
|
||||
attachment_count?: number
|
||||
attachment_bytes?: number
|
||||
warnings?: string[]
|
||||
destructive_summary?: string[]
|
||||
}
|
||||
export type BackupRestoreResult = { restored: number; mode: BackupMode; already_imported?: boolean; cleanup_retried?: boolean }
|
||||
|
||||
export function downloadFullBackup(signal?: AbortSignal) {
|
||||
return requestBlob('/backup/export.zip', { signal })
|
||||
}
|
||||
|
||||
export function preflightBackup(file: File, mode: BackupMode, signal?: AbortSignal) {
|
||||
return uploadJson<BackupPreflight>(`/backup/preflight?mode=${mode}`, file, { signal })
|
||||
}
|
||||
|
||||
export function restoreBackup(preflightToken: string, mode: BackupMode, signal?: AbortSignal) {
|
||||
return requestJson<BackupRestoreResult>('/backup/restore', { method: 'POST', body: { preflight_token: preflightToken, mode }, signal })
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export type FastApiValidationIssue = { loc?: Array<string | number>; msg?: string; type?: string }
|
||||
|
||||
export function formatApiErrorDetail(detail: unknown): string {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) return detail.map((issue) => {
|
||||
if (!issue || typeof issue !== 'object') return String(issue)
|
||||
const value = issue as FastApiValidationIssue
|
||||
const location = value.loc?.filter((part) => part !== 'body').join('.')
|
||||
return [location, value.msg].filter(Boolean).join(':') || value.type || '请求数据无效'
|
||||
}).join(';')
|
||||
if (detail && typeof detail === 'object') {
|
||||
const value = detail as { message?: unknown; code?: unknown }
|
||||
if (typeof value.message === 'string') return value.message
|
||||
if (typeof value.code === 'string') return value.code
|
||||
}
|
||||
return '请求失败'
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code?: string
|
||||
readonly detail: unknown
|
||||
|
||||
constructor(message: string, status: number, code?: string, detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { csrfHeader } from '../lib/csrf'
|
||||
import { ApiError, formatApiErrorDetail } from './errors'
|
||||
|
||||
export type JsonRequestOptions = Omit<RequestInit, 'body'> & { body?: unknown }
|
||||
|
||||
function apiUrl(path: string) {
|
||||
return path.startsWith('/api/') ? path : `/api/v1${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, options: RequestInit = {}) {
|
||||
const headers = new Headers(options.headers)
|
||||
const csrf = csrfHeader(options.method)
|
||||
if (csrf['x-csrf-token']) headers.set('x-csrf-token', csrf['x-csrf-token'])
|
||||
const response = await fetch(apiUrl(path), { credentials: 'include', ...options, headers })
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
const body = contentType.includes('json') ? await response.json().catch(() => ({})) : await response.text().catch(() => '')
|
||||
const detail = body && typeof body === 'object' && 'detail' in body ? body.detail : body
|
||||
const code = body && typeof body === 'object' && typeof body.code === 'string'
|
||||
? body.code
|
||||
: detail && typeof detail === 'object' && !Array.isArray(detail) && typeof detail.code === 'string' ? detail.code : undefined
|
||||
throw new ApiError(formatApiErrorDetail(detail), response.status, code, detail)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export async function requestJson<T>(path: string, options: JsonRequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers)
|
||||
const { body: ignoredBody, ...requestOptions } = options
|
||||
void ignoredBody
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body)
|
||||
if (body !== undefined) headers.set('content-type', 'application/json')
|
||||
const response = await apiFetch(path, { ...requestOptions, headers, body })
|
||||
return response.status === 204 ? undefined as T : await response.json() as T
|
||||
}
|
||||
|
||||
export async function requestBlob(path: string, options: RequestInit = {}): Promise<Blob> {
|
||||
return (await apiFetch(path, options)).blob()
|
||||
}
|
||||
|
||||
export async function uploadJson<T>(path: string, file: File, options: Omit<RequestInit, 'body'> = {}): Promise<T> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await apiFetch(path, { ...options, method: options.method ?? 'POST', body: form })
|
||||
return response.status === 204 ? undefined as T : await response.json() as T
|
||||
}
|
||||
|
||||
export { ApiError } from './errors'
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './errors'
|
||||
export * from './http'
|
||||
export * from './backups'
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref } from 'vue'
|
||||
import AppSheet from './AppSheet.vue'
|
||||
|
||||
export type AppDialogOptions = {
|
||||
title: string
|
||||
description?: string
|
||||
label?: string
|
||||
initial?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
danger?: boolean
|
||||
validate?: (value: string) => string | null
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const busy = ref(false)
|
||||
const options = ref<AppDialogOptions>({ title: '' })
|
||||
const value = ref('')
|
||||
const error = ref('')
|
||||
let resolveDialog: ((value: boolean | string | null) => void) | null = null
|
||||
|
||||
function finish(result: boolean | string | null) {
|
||||
if (!open.value || busy.value) return
|
||||
open.value = false
|
||||
resolveDialog?.(result)
|
||||
resolveDialog = null
|
||||
}
|
||||
function cancel() { finish(options.value.label ? null : false) }
|
||||
function confirm() {
|
||||
if (options.value.label) {
|
||||
const message = options.value.validate?.(value.value) ?? null
|
||||
if (message) { error.value = message; void nextTick(() => document.querySelector<HTMLElement>('#app-dialog-error')?.focus()); return }
|
||||
finish(value.value)
|
||||
} else finish(true)
|
||||
}
|
||||
function show(next: AppDialogOptions) {
|
||||
if (resolveDialog) resolveDialog(options.value.label ? null : false)
|
||||
options.value = next
|
||||
value.value = next.initial ?? ''
|
||||
error.value = ''
|
||||
open.value = true
|
||||
return new Promise<boolean | string | null>((resolve) => { resolveDialog = resolve })
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
resolveDialog?.(options.value.label ? null : false)
|
||||
resolveDialog = null
|
||||
})
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppSheet :open="open" variant="actions" panel-class="app-dialog" title-id="app-dialog-title" :description-id="options.description ? 'app-dialog-description' : undefined" initial-focus="[data-dialog-initial]" :busy="busy" @close="cancel" @submit.prevent="confirm">
|
||||
<header class="app-sheet__header"><div><h2 id="app-dialog-title">{{ options.title }}</h2><p v-if="options.description" id="app-dialog-description">{{ options.description }}</p></div></header>
|
||||
<div v-if="options.label" class="app-sheet__body"><label>{{ options.label }}<input v-model="value" data-dialog-initial class="modal-input" :aria-invalid="Boolean(error)" :aria-describedby="error ? 'app-dialog-error' : undefined" @input="error=''" /></label><small v-if="error" id="app-dialog-error" class="field-error" role="alert" tabindex="-1">{{ error }}</small></div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" data-dialog-initial :disabled="busy" @click="cancel">{{ options.cancelText ?? '取消' }}</button><button type="submit" :class="options.danger ? 'danger-button' : 'primary-small'" :disabled="busy">{{ options.confirmText ?? '确定' }}</button></footer>
|
||||
</AppSheet>
|
||||
</template>
|
||||
@@ -0,0 +1,262 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import AppSheet from './AppSheet.vue'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => { cleanups.splice(0).forEach((fn) => fn()); document.body.innerHTML = '' })
|
||||
|
||||
async function mountSheet(options: { busy?: boolean; initialFocus?: string; modal?: boolean } = {}) {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.textContent = 'open'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const open = ref(true)
|
||||
const close = vi.fn(() => { open.value = false })
|
||||
const app = createApp({
|
||||
setup: () => () => h(AppSheet, {
|
||||
open: open.value,
|
||||
titleId: 'sheet-title',
|
||||
descriptionId: 'sheet-description',
|
||||
busy: options.busy,
|
||||
modal: options.modal,
|
||||
initialFocus: options.initialFocus,
|
||||
onClose: close,
|
||||
}, {
|
||||
default: () => [h('h2', { id: 'sheet-title' }, '标题'), h('p', { id: 'sheet-description' }, '说明'), h('button', { id: 'first' }, 'first'), h('button', { id: 'last' }, 'last')],
|
||||
}),
|
||||
})
|
||||
app.mount(host)
|
||||
cleanups.push(() => app.unmount())
|
||||
for (const element of document.querySelectorAll<HTMLElement>('#first,#last')) {
|
||||
Object.defineProperty(element, 'getClientRects', { configurable: true, value: () => [{ width: 20, height: 20 }] })
|
||||
}
|
||||
await nextTick(); await nextTick()
|
||||
return { host, opener, open, close }
|
||||
}
|
||||
|
||||
describe('AppSheet', () => {
|
||||
it('teleports an accessible modal and makes application background inert', async () => {
|
||||
const { host } = await mountSheet({ initialFocus: '#last' })
|
||||
const dialog = document.querySelector<HTMLElement>('#overlay-root [role="dialog"]')!
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||
expect(dialog.getAttribute('aria-labelledby')).toBe('sheet-title')
|
||||
expect(dialog.getAttribute('aria-describedby')).toBe('sheet-description')
|
||||
expect(document.activeElement?.id).toBe('last')
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
expect(host.getAttribute('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('traps Tab and restores focus after closing', async () => {
|
||||
const { opener } = await mountSheet({ initialFocus: '#first' })
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
const first = document.querySelector<HTMLButtonElement>('#first')!
|
||||
const last = document.querySelector<HTMLButtonElement>('#last')!
|
||||
last.focus()
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }))
|
||||
expect(document.activeElement).toBe(first)
|
||||
first.focus()
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }))
|
||||
expect(document.activeElement).toBe(last)
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('blocks scrim and Escape closing while busy', async () => {
|
||||
const { close } = await mountSheet({ busy: true })
|
||||
document.querySelector<HTMLElement>('.app-overlay')!.click()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a real form when submit listeners are provided', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const submitted = vi.fn()
|
||||
const app = createApp({ setup: () => () => h(AppSheet, { open:true, titleId:'form-title', onSubmit:(event: Event) => { event.preventDefault(); submitted() } }, {
|
||||
default:() => [h('h2',{id:'form-title'},'form'), h('button',{type:'submit'},'save')],
|
||||
}) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(dialog.tagName).toBe('FORM')
|
||||
dialog.querySelector<HTMLButtonElement>('button[type="submit"]')!.click()
|
||||
expect(submitted).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps desktop non-modal details inline without inerting the app', async () => {
|
||||
const host = document.createElement('main'); document.body.append(host)
|
||||
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:false, titleId:'detail-title' }, {
|
||||
default:() => [h('h2',{id:'detail-title'},'detail'), h('button','close')],
|
||||
}) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
const dialog = host.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
expect(dialog).not.toBeNull()
|
||||
expect(dialog.getAttribute('aria-modal')).toBeNull()
|
||||
expect(document.querySelector('#overlay-root [role="dialog"]')).toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
})
|
||||
|
||||
it('activates and deactivates the overlay when modal changes while open', async () => {
|
||||
const host = document.createElement('main'); document.body.append(host)
|
||||
const modal = ref(false)
|
||||
const app = createApp({ setup: () => () => h(AppSheet, { open:true, modal:modal.value, titleId:'dynamic-title' }, {
|
||||
default:() => [h('h2',{id:'dynamic-title'},'detail'), h('button',{id:'dynamic-close'},'close')],
|
||||
}) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
|
||||
modal.value = true; await nextTick(); await nextTick()
|
||||
expect(document.querySelector('#overlay-root [role="dialog"]')).not.toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
|
||||
modal.value = false; await nextTick(); await nextTick()
|
||||
expect(host.querySelector('[role="dialog"]')).not.toBeNull()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps background inert until the last stacked modal closes', async () => {
|
||||
const host = document.createElement('main'); document.body.append(host)
|
||||
const first = ref(true); const second = ref(true)
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:first.value, titleId:'stack-one', onClose:() => { first.value=false } }, { default:() => h('h2',{id:'stack-one'},'one') }),
|
||||
h(AppSheet, { open:second.value, titleId:'stack-two', onClose:() => { second.value=false } }, { default:() => h('h2',{id:'stack-two'},'two') }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
second.value=false; await nextTick(); await nextTick()
|
||||
expect(host.hasAttribute('inert')).toBe(true)
|
||||
first.value=false; await nextTick(); await nextTick()
|
||||
expect(host.hasAttribute('inert')).toBe(false)
|
||||
expect(host.getAttribute('aria-hidden')).toBeNull()
|
||||
})
|
||||
|
||||
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)
|
||||
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
||||
void dialog.value!.show({ title:'prompt', label:'name' }); await nextTick(); await nextTick()
|
||||
expect(document.activeElement?.tagName).toBe('INPUT')
|
||||
document.querySelector<HTMLButtonElement>('.app-dialog .secondary')!.click(); await nextTick()
|
||||
void dialog.value!.show({ title:'confirm' }); await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(document.querySelector('.app-dialog .secondary'))
|
||||
})
|
||||
|
||||
it('settles replaced and unmounted dialog promises safely', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const dialog = ref<InstanceType<typeof AppDialog> | null>(null)
|
||||
const app = createApp({ setup: () => () => h(AppDialog, { ref:dialog }) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick()
|
||||
const first = dialog.value!.show({ title:'one' })
|
||||
const second = dialog.value!.show({ title:'two', label:'name' })
|
||||
await expect(first).resolves.toBe(false)
|
||||
app.unmount()
|
||||
await expect(second).resolves.toBe(null)
|
||||
})
|
||||
|
||||
it('returns focus to the lower overlay when same-tick upper overlay closes', async () => {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.id = 'stack-opener'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const lowerOpen = ref(true); const upperOpen = ref(true)
|
||||
const visibleRef = (element: unknown) => {
|
||||
if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] })
|
||||
}
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:lowerOpen.value, titleId:'focus-lower' }, { default:() => [h('h2',{id:'focus-lower'},'lower'), h('button',{id:'focus-lower-button', ref:visibleRef},'lower button')] }),
|
||||
h(AppSheet, { open:upperOpen.value, titleId:'focus-upper' }, { default:() => [h('h2',{id:'focus-upper'},'upper'), h('button',{id:'focus-upper-button', ref:visibleRef},'upper button')] }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
expect(document.activeElement?.id).toBe('focus-upper-button')
|
||||
|
||||
upperOpen.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement?.id).toBe('focus-lower-button')
|
||||
})
|
||||
|
||||
it('restores the background opener only after the last stacked overlay closes', async () => {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.id = 'last-stack-opener'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const lowerOpen = ref(true); const upperOpen = ref(true)
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:lowerOpen.value, titleId:'last-lower' }, { default:() => [h('h2',{id:'last-lower'},'lower'), h('button',{id:'last-lower-button'},'lower button')] }),
|
||||
h(AppSheet, { open:upperOpen.value, titleId:'last-upper' }, { default:() => h('h2',{id:'last-upper'},'upper') }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
|
||||
upperOpen.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement).not.toBe(opener)
|
||||
lowerOpen.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(opener)
|
||||
})
|
||||
|
||||
it('keeps focus in the upper overlay when a non-top lower overlay closes', async () => {
|
||||
const host = document.createElement('main')
|
||||
const opener = document.createElement('button')
|
||||
opener.id = 'lower-opener'
|
||||
document.body.append(host, opener)
|
||||
opener.focus()
|
||||
const first = ref(true); const second = ref(false)
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:first.value, titleId:'lower' }, { default:() => [h('h2',{id:'lower'},'lower'), h('button',{id:'lower-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'lower button')] }),
|
||||
h(AppSheet, { open:second.value, titleId:'upper' }, { default:() => [h('h2',{id:'upper'},'upper'), h('button',{id:'upper-button', ref:(element: unknown) => { if (element instanceof HTMLElement) Object.defineProperty(element, 'getClientRects', { configurable:true, value:() => [{ width:20, height:20 }] }) }},'upper button')] }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
second.value=true; await nextTick(); await nextTick()
|
||||
const upper = document.querySelector<HTMLButtonElement>('#upper-button')!
|
||||
expect(document.activeElement).toBe(upper)
|
||||
first.value=false; await nextTick(); await nextTick()
|
||||
expect(document.activeElement).toBe(upper)
|
||||
})
|
||||
|
||||
it('skips focusables hidden by ancestors, aria-hidden, inert, styles, disabled state, or empty client rects', async () => {
|
||||
const { host } = await mountSheet({ initialFocus: '#first' })
|
||||
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!
|
||||
dialog.querySelector('#first')?.remove()
|
||||
dialog.querySelector('#last')?.remove()
|
||||
const hiddenParent = document.createElement('div')
|
||||
hiddenParent.hidden = true
|
||||
hiddenParent.innerHTML = '<button id="hidden-child">hidden</button>'
|
||||
const ariaParent = document.createElement('div')
|
||||
ariaParent.setAttribute('aria-hidden', 'true')
|
||||
ariaParent.innerHTML = '<button id="aria-child">aria</button>'
|
||||
const inertParent = document.createElement('div')
|
||||
inertParent.setAttribute('inert', '')
|
||||
inertParent.innerHTML = '<button id="inert-child">inert</button>'
|
||||
const displayNone = document.createElement('button')
|
||||
displayNone.id = 'display-none'; displayNone.style.display = 'none'
|
||||
const invisible = document.createElement('button')
|
||||
invisible.id = 'invisible'; invisible.style.visibility = 'hidden'
|
||||
const disabled = document.createElement('button')
|
||||
disabled.id = 'disabled'; disabled.disabled = true
|
||||
const noRect = document.createElement('button')
|
||||
noRect.id = 'no-rect'
|
||||
const visible = document.createElement('button')
|
||||
visible.id = 'visible'
|
||||
Object.defineProperty(visible, 'getClientRects', { value: () => [{ width: 20, height: 20 }] })
|
||||
dialog.append(hiddenParent, ariaParent, inertParent, displayNone, invisible, disabled, noRect, visible)
|
||||
dialog.focus()
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key:'Tab', bubbles:true, cancelable:true }))
|
||||
expect(document.activeElement).toBe(visible)
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('only closes the top overlay on Escape', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const first = ref(true); const second = ref(true); const calls: string[] = []
|
||||
const app = createApp({ setup: () => () => h('div', [
|
||||
h(AppSheet, { open:first.value, titleId:'one', onClose:() => { calls.push('one'); first.value=false } }, { default:() => h('h2',{id:'one'},'one') }),
|
||||
h(AppSheet, { open:second.value, titleId:'two', onClose:() => { calls.push('two'); second.value=false } }, { default:() => h('h2',{id:'two'},'two') }),
|
||||
]) })
|
||||
app.mount(host); cleanups.push(() => app.unmount()); await nextTick(); await nextTick()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key:'Escape', bubbles:true, cancelable:true }))
|
||||
await nextTick()
|
||||
expect(calls).toEqual(['two'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { isTopOverlay, overlayRoot, popOverlay, pushOverlay } from '../composables/useOverlayStack'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
open: boolean
|
||||
titleId?: string
|
||||
descriptionId?: string
|
||||
label?: string
|
||||
busy?: boolean
|
||||
initialFocus?: string
|
||||
modal?: boolean
|
||||
closeOnScrim?: boolean
|
||||
variant?: 'create' | 'detail' | 'actions'
|
||||
panelClass?: string
|
||||
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '' })
|
||||
defineOptions({ inheritAttrs: false })
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const panel = ref<HTMLElement | null>(null)
|
||||
let overlayId: symbol | null = null
|
||||
|
||||
function requestClose() {
|
||||
if (!props.busy && (!props.modal || isTopOverlay(overlayId))) emit('close')
|
||||
}
|
||||
function scrimClose(event: MouseEvent) {
|
||||
if (props.closeOnScrim && event.target === event.currentTarget) requestClose()
|
||||
}
|
||||
function isVisibleFocusable(element: HTMLElement) {
|
||||
if (element.matches(':disabled') || element.closest('[hidden],[aria-hidden="true"],[inert]')) return false
|
||||
const style = window.getComputedStyle(element)
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false
|
||||
return element.getClientRects().length > 0 || (element.offsetWidth > 0 && element.offsetHeight > 0)
|
||||
}
|
||||
function focusables() {
|
||||
if (!panel.value) return []
|
||||
return Array.from(panel.value.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'))
|
||||
.filter(isVisibleFocusable)
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if (!props.modal || event.key !== 'Tab' || !isTopOverlay(overlayId)) return
|
||||
const controls = focusables()
|
||||
if (!controls.length) { event.preventDefault(); panel.value?.focus(); return }
|
||||
const first = controls[0]
|
||||
const last = controls[controls.length - 1]
|
||||
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.value)) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && (document.activeElement === last || !controls.includes(document.activeElement as HTMLElement))) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
function focusIntoPanel() {
|
||||
if (!panel.value?.contains(document.activeElement)) {
|
||||
const target = props.initialFocus ? panel.value?.querySelector<HTMLElement>(props.initialFocus) : null
|
||||
;(target ?? focusables()[0] ?? panel.value)?.focus()
|
||||
}
|
||||
}
|
||||
async function activate() {
|
||||
if (!props.open || !props.modal || overlayId) return
|
||||
overlayId = pushOverlay(requestClose, () => props.busy, focusIntoPanel)
|
||||
await nextTick()
|
||||
if (!props.open || !props.modal || !overlayId) return
|
||||
focusIntoPanel()
|
||||
}
|
||||
function deactivate() {
|
||||
if (overlayId) popOverlay(overlayId)
|
||||
overlayId = null
|
||||
}
|
||||
watch([() => props.open, () => props.modal], ([open, modal]) => {
|
||||
if (open && modal) void activate()
|
||||
else deactivate()
|
||||
}, { immediate: true })
|
||||
onBeforeUnmount(deactivate)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modal" :to="overlayRoot()">
|
||||
<div v-if="open" class="app-overlay app-sheet-mask" :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>
|
||||
</div>
|
||||
</Teleport>
|
||||
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
@@ -36,5 +36,6 @@ describe('add-task CalendarPicker integration', () => {
|
||||
expect(picker).toContain('data-action="clear"')
|
||||
expect(picker).toContain('data-action="cancel"')
|
||||
expect(picker).toContain('data-action="done"')
|
||||
expect(picker).toContain("event.preventDefault(); event.stopPropagation(); close()")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,7 @@ function onGridKey(event: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
function onDialogKey(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return }
|
||||
if (event.key !== 'Tab' || !dialog.value) return
|
||||
const focusables = [...dialog.value.querySelectorAll<HTMLElement>('button:not([disabled])')]
|
||||
if (!focusables.length) return
|
||||
|
||||
@@ -17,7 +17,8 @@ async function mount(overrides: Record<string, unknown> = {}) {
|
||||
const events: Record<string, unknown[]> = { saved: [], close: [] }
|
||||
const defaultRequest = vi.fn(async (_path: string, _options?: RequestInit): Promise<unknown> => ({ ...memo, title: '新标题', version: 4 }))
|
||||
const request = (overrides.request ?? defaultRequest) as (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const app = createApp(() => h(MemoEditor, { memo, request, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
||||
const confirmAction = (overrides.confirmAction ?? vi.fn(async () => true)) as (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean>
|
||||
const app = createApp(() => h(MemoEditor, { memo, request, confirmAction, onSaved: (v: unknown) => events.saved.push(v), onClose: () => events.close.push(true), ...overrides }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
return { host, request: request as ReturnType<typeof vi.fn>, events }
|
||||
}
|
||||
@@ -105,19 +106,19 @@ describe('MemoEditor', () => {
|
||||
})
|
||||
|
||||
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const confirmAction = vi.fn(async () => false)
|
||||
const request = vi.fn()
|
||||
const clean = await mount({ memo: draft, request })
|
||||
const clean = await mount({ memo: draft, request, confirmAction })
|
||||
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(confirmAction).not.toHaveBeenCalled()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(clean.events.close).toEqual([true])
|
||||
|
||||
const edited = await mount({ memo: draft, request })
|
||||
const edited = await mount({ memo: draft, request, confirmAction })
|
||||
const body = edited.host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(confirm).toHaveBeenCalledOnce()
|
||||
edited.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(confirmAction).toHaveBeenCalledOnce()
|
||||
expect(edited.events.close).toEqual([])
|
||||
})
|
||||
|
||||
@@ -226,22 +227,15 @@ describe('MemoEditor', () => {
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('traps mobile focus, closes on Escape, and leaves focus restoration to the panel owner', async () => {
|
||||
const opener = document.createElement('button'); document.body.append(opener); opener.focus()
|
||||
it('leaves mobile modal focus trapping and Escape close to AppSheet', async () => {
|
||||
const { host, events } = await mount({ mobile: true })
|
||||
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
||||
expect(dialog.getAttribute('aria-modal')).toBe('true')
|
||||
const last = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')].at(-1)!
|
||||
last.focus()
|
||||
const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })
|
||||
dialog.dispatchEvent(tab)
|
||||
expect(tab.defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(dialog.querySelector('button'))
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
const editor = host.querySelector<HTMLElement>('.memo-editor')!
|
||||
expect(editor.getAttribute('aria-modal')).toBeNull()
|
||||
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
editor.dispatchEvent(escape)
|
||||
await nextTick()
|
||||
expect(events.close).toEqual([true])
|
||||
expect(document.activeElement).not.toBe(opener)
|
||||
opener.remove()
|
||||
expect(escape.defaultPrevented).toBe(false)
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a stale reload after its editor selection changes', async () => {
|
||||
@@ -424,15 +418,15 @@ describe('MemoEditor', () => {
|
||||
})
|
||||
|
||||
it('guards dirty close and allows clean close', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { host, events } = await mount()
|
||||
const confirmAction = vi.fn(async () => false)
|
||||
const { host, events } = await mount({ confirmAction })
|
||||
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
body.value = '改过'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(confirm).toHaveBeenCalled()
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(confirmAction).toHaveBeenCalled()
|
||||
expect(events.close).toEqual([])
|
||||
confirm.mockReturnValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
confirmAction.mockResolvedValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await flush()
|
||||
expect(events.close).toEqual([true])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ export type MemoDraft = { id: null; title: string; content: string; version: nul
|
||||
export type Memo = MemoRecord
|
||||
export type MemoEditorValue = MemoRecord | MemoDraft
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number }>(), { selectionToken: 0 })
|
||||
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number; confirmAction?: (options: { title: string; description?: string; confirmText?: string; danger?: boolean }) => Promise<boolean> }>(), { selectionToken: 0 })
|
||||
const emit = defineEmits<{ saveStarted: [id: string | null, selectionToken: number]; saveFinished: [selectionToken: number]; lifecycleStarted: [id: string, selectionToken: number]; lifecycleFinished: [selectionToken: number]; saved: [memo: MemoRecord, selectionToken: number]; close: []; deleted: [id: string, selectionToken: number]; restored: [memo: MemoRecord, selectionToken: number]; purged: [id: string, selectionToken: number]; notice: [message: string] }>()
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
@@ -18,7 +18,6 @@ let lifecycleGeneration = 0
|
||||
const error = ref('')
|
||||
const conflict = ref(false)
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const initial = ref({ title: '', content: '' })
|
||||
const memoPreview = ref(false)
|
||||
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -35,8 +34,8 @@ watch(() => props.selectionToken, () => {
|
||||
lifecycleGeneration += 1
|
||||
lifecycleBusy.value = false
|
||||
})
|
||||
function close() {
|
||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
async function close() {
|
||||
if (dirty.value && !(await props.confirmAction?.({ title: '放弃未保存的更改?', description: '关闭后,当前草稿不会保存。', confirmText: '放弃更改', danger: true }))) return
|
||||
emit('close')
|
||||
}
|
||||
function validate() {
|
||||
@@ -91,7 +90,7 @@ async function reload() {
|
||||
async function remove() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
||||
if (!(await props.confirmAction?.({ title: `把“${props.memo.title}”移到回收站?`, description: '之后可以在回收站恢复。', confirmText: '移到回收站', danger: true }))) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
@@ -130,7 +129,7 @@ async function restore() {
|
||||
async function purge() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
||||
if (!(await props.confirmAction?.({ title: `永久删除“${props.memo.title}”?`, description: '此操作无法撤销。', confirmText: '永久删除', danger: true }))) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
@@ -170,14 +169,7 @@ function handleMemoBodyShortcut(event: KeyboardEvent) {
|
||||
formatMemoBody(format)
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
||||
if (event.key === 'Escape') { event.preventDefault(); close(); return }
|
||||
if (!props.mobile || event.key !== 'Tab' || !root.value) return
|
||||
const controls = [...root.value.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled)')]
|
||||
if (!controls.length) return
|
||||
const first = controls[0], last = controls.at(-1)!
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save() }
|
||||
}
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||
@@ -186,7 +178,7 @@ defineExpose({ dirty, requestClose: close })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside ref="root" class="memo-editor" role="dialog" :aria-modal="mobile ? 'true' : undefined" aria-labelledby="memo-editor-title" @keydown="keydown">
|
||||
<div class="memo-editor" @keydown="keydown">
|
||||
<header><span id="memo-editor-title">备忘录详情</span><button type="button" aria-label="关闭备忘录" @click="close"><X/></button></header>
|
||||
<div class="memo-editor__fields">
|
||||
<label>标题<input ref="titleInput" v-model="title" maxlength="200" aria-label="备忘录标题" :disabled="Boolean(memo.deleted_at)"></label>
|
||||
@@ -213,5 +205,5 @@ defineExpose({ dirty, requestClose: close })
|
||||
</div>
|
||||
<footer v-if="!memo.deleted_at"><button v-if="memo.id !== null" type="button" class="danger-text" :disabled="saving || lifecycleBusy" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || lifecycleBusy || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
||||
<footer v-else><button type="button" class="secondary" :disabled="lifecycleBusy" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" :disabled="lifecycleBusy" @click="purge"><Trash2/>永久删除</button></footer>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
type OverlayEntry = {
|
||||
id: symbol
|
||||
close: () => void
|
||||
busy: () => boolean
|
||||
restoreFocus: HTMLElement | null
|
||||
focusPanel: () => void
|
||||
}
|
||||
|
||||
const stack: OverlayEntry[] = []
|
||||
const background = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>()
|
||||
let listening = false
|
||||
|
||||
function root() {
|
||||
let element = document.getElementById('overlay-root')
|
||||
if (!element) {
|
||||
element = document.createElement('div')
|
||||
element.id = 'overlay-root'
|
||||
document.body.appendChild(element)
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function syncBackground() {
|
||||
const overlayRoot = root()
|
||||
if (stack.length) {
|
||||
for (const child of Array.from(document.body.children)) {
|
||||
if (!(child instanceof HTMLElement) || child === overlayRoot || background.has(child)) continue
|
||||
background.set(child, { inert: child.hasAttribute('inert'), ariaHidden: child.getAttribute('aria-hidden') })
|
||||
child.setAttribute('inert', '')
|
||||
child.setAttribute('aria-hidden', 'true')
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const [element, state] of background) {
|
||||
if (!state.inert) element.removeAttribute('inert')
|
||||
if (state.ariaHidden === null) element.removeAttribute('aria-hidden')
|
||||
else element.setAttribute('aria-hidden', state.ariaHidden)
|
||||
}
|
||||
background.clear()
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
const entry = stack.at(-1)
|
||||
if (!entry || entry.busy()) return
|
||||
event.preventDefault()
|
||||
entry.close()
|
||||
}
|
||||
|
||||
export function overlayRoot() { return root() }
|
||||
|
||||
export function pushOverlay(close: () => void, busy: () => boolean, focusPanel: () => void) {
|
||||
const entry: OverlayEntry = {
|
||||
id: Symbol('overlay'), close, busy, focusPanel,
|
||||
restoreFocus: document.activeElement instanceof HTMLElement ? document.activeElement : null,
|
||||
}
|
||||
stack.push(entry)
|
||||
if (!listening) { document.addEventListener('keydown', onKeydown); listening = true }
|
||||
syncBackground()
|
||||
return entry.id
|
||||
}
|
||||
|
||||
export function popOverlay(id: symbol) {
|
||||
const index = stack.findIndex((entry) => entry.id === id)
|
||||
if (index < 0) return
|
||||
const wasTop = index === stack.length - 1
|
||||
const [entry] = stack.splice(index, 1)
|
||||
if (!stack.length && listening) { document.removeEventListener('keydown', onKeydown); listening = false }
|
||||
syncBackground()
|
||||
if (!wasTop) return
|
||||
const newTop = stack.at(-1)
|
||||
void nextTick(() => {
|
||||
if (newTop) newTop.focusPanel()
|
||||
else if (entry.restoreFocus?.isConnected) entry.restoreFocus.focus()
|
||||
})
|
||||
}
|
||||
|
||||
export function isTopOverlay(id: symbol | null) {
|
||||
return Boolean(id && stack.at(-1)?.id === id)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { backupFileSnapshot, isCurrentBackupSnapshot, shouldCommitBackupPreflight } from './backup-preflight-state'
|
||||
|
||||
describe('backup preflight identity', () => {
|
||||
it('rejects a preflight result after the selected file changes even when the old request finishes last', () => {
|
||||
const oldFile = new File(['old'], 'old.zip', { lastModified: 10 })
|
||||
const newFile = new File(['new'], 'new.zip', { lastModified: 20 })
|
||||
const started = backupFileSnapshot(oldFile, 'merge')
|
||||
|
||||
expect(isCurrentBackupSnapshot(started, newFile, 'merge')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not let a token for one filename authorize another file with matching metadata', () => {
|
||||
const first = new File(['same'], 'first.zip', { lastModified: 10 })
|
||||
const second = new File(['same'], 'second.zip', { lastModified: 10 })
|
||||
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), second, 'merge')).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates a preflight when restore mode changes', () => {
|
||||
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
|
||||
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(file, 'merge'), file, 'replace')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an older generation even when its file snapshot still matches', () => {
|
||||
const file = new File(['zip'], 'backup.zip', { lastModified: 10 })
|
||||
expect(shouldCommitBackupPreflight(1, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(false)
|
||||
expect(shouldCommitBackupPreflight(2, 2, backupFileSnapshot(file, 'merge'), file, 'merge')).toBe(true)
|
||||
})
|
||||
|
||||
it('binds identity to the exact File object as well as name size and modified time', () => {
|
||||
const first = new File(['same'], 'backup.zip', { lastModified: 10 })
|
||||
const replacement = new File(['same'], 'backup.zip', { lastModified: 10 })
|
||||
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), replacement, 'merge')).toBe(false)
|
||||
expect(isCurrentBackupSnapshot(backupFileSnapshot(first, 'merge'), first, 'merge')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BackupMode } from '../api'
|
||||
|
||||
export type BackupFileSnapshot = {
|
||||
file: File
|
||||
name: string
|
||||
size: number
|
||||
lastModified: number
|
||||
mode: BackupMode
|
||||
}
|
||||
|
||||
export function backupFileSnapshot(file: File, mode: BackupMode): BackupFileSnapshot {
|
||||
return { file, name: file.name, size: file.size, lastModified: file.lastModified, mode }
|
||||
}
|
||||
|
||||
export function isCurrentBackupSnapshot(snapshot: BackupFileSnapshot, file: File | null, mode: BackupMode) {
|
||||
return Boolean(file)
|
||||
&& snapshot.file === file
|
||||
&& snapshot.name === file!.name
|
||||
&& snapshot.size === file!.size
|
||||
&& snapshot.lastModified === file!.lastModified
|
||||
&& snapshot.mode === mode
|
||||
}
|
||||
|
||||
export function shouldCommitBackupPreflight(
|
||||
generation: number,
|
||||
currentGeneration: number,
|
||||
snapshot: BackupFileSnapshot,
|
||||
file: File | null,
|
||||
mode: BackupMode,
|
||||
) {
|
||||
return generation === currentGeneration && isCurrentBackupSnapshot(snapshot, file, mode)
|
||||
}
|
||||
|
||||
export function isLegacyBackup(file: File) {
|
||||
return !file.name.toLowerCase().endsWith('.zip')
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextDialogFocusIndex } from './list-purge'
|
||||
|
||||
describe('archived list purge dialog behavior', () => {
|
||||
it('wraps Tab focus between the cancel and destructive actions', () => {
|
||||
expect(nextDialogFocusIndex(0, 2, true)).toBe(1)
|
||||
expect(nextDialogFocusIndex(1, 2, false)).toBe(0)
|
||||
})
|
||||
|
||||
it('leaves focus alone while moving between interior controls', () => {
|
||||
expect(nextDialogFocusIndex(1, 3, true)).toBeNull()
|
||||
expect(nextDialogFocusIndex(1, 3, false)).toBeNull()
|
||||
expect(nextDialogFocusIndex(0, 0, false)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +0,0 @@
|
||||
export function nextDialogFocusIndex(currentIndex: number, controlCount: number, shiftKey: boolean) {
|
||||
if (controlCount < 2) return null
|
||||
if (shiftKey && currentIndex === 0) return controlCount - 1
|
||||
if (!shiftKey && currentIndex === controlCount - 1) return 0
|
||||
return null
|
||||
}
|
||||
@@ -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{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))}}
|
||||
.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}
|
||||
@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(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||
|
||||
+11
-14
File diff suppressed because one or more lines are too long
+75
-48
@@ -99,10 +99,19 @@ describe('mobile navigation styles', () => {
|
||||
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
|
||||
})
|
||||
|
||||
it('keeps the mobile More sheet visible when it is rendered', () => {
|
||||
expect(css).not.toContain('.more-mask{display:none}')
|
||||
expect(css).toContain('@media(max-width:930px){.shell')
|
||||
expect(css).toContain('.more-mask{position:fixed;z-index:45;')
|
||||
it('has no unreachable mobile More state, template, or styles', () => {
|
||||
expect(app).not.toContain('mobileMore')
|
||||
expect(app).not.toContain('mobile-more-menu')
|
||||
expect(app).not.toContain('more-mask')
|
||||
expect(css).not.toContain('.more-mask')
|
||||
expect(css).not.toContain('.more-sheet')
|
||||
expect(css).not.toContain('.task-compose-mask')
|
||||
expect(css).not.toContain('.habit-detail-mask')
|
||||
expect(css).not.toContain('.countdown-detail-mask')
|
||||
expect(css).not.toContain('.countdown-modal-mask')
|
||||
expect(css).not.toContain('.modal-mask')
|
||||
expect(css).not.toContain('.modal-box')
|
||||
expect(css).not.toContain('.countdown-compose-enter')
|
||||
})
|
||||
|
||||
it('lets the mobile sidebar scrim cover the outside area and stay below the sidebar', () => {
|
||||
@@ -125,7 +134,7 @@ describe('settings sessions and audit activity', () => {
|
||||
expect(mvpPanel).toContain('class="session-card-actions"')
|
||||
expect(mvpPanel).toContain('撤销其他所有会话')
|
||||
expect(mvpPanel).toContain("request('/sessions/others', { method: 'DELETE' })")
|
||||
expect(mvpPanel).toContain("confirm('撤销其他所有设备的登录会话?当前设备会保持登录。')")
|
||||
expect(mvpPanel).toContain("confirmAction('撤销其他所有设备的登录会话?', '当前设备会保持登录。')")
|
||||
expect(css).toContain('.session-card-actions{')
|
||||
})
|
||||
|
||||
@@ -186,10 +195,11 @@ describe('solid cream material system', () => {
|
||||
expect(css).toContain('.sidebar{background:var(--surface-canvas)')
|
||||
expect(css).toContain('main{background:var(--surface-base)}')
|
||||
expect(css).toContain('.detail,.bottom{background:var(--surface-raised)')
|
||||
expect(css).toContain('.app-sheet,.modal-box,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||||
expect(css).toContain('.app-sheet,.calendar-picker,.sidebar-popover,.archived-row-actions{background:var(--surface-raised)')
|
||||
expect(css).toContain('.toast{background:#3b342c')
|
||||
expect(css).toContain('.error-toast{background:var(--danger)')
|
||||
expect(css).toContain('.modal-mask,.task-compose-mask,.habit-detail-mask,.countdown-detail-mask,.countdown-modal-mask,.app-sheet-mask.app-sheet-mask,.scrim,.more-mask{background:var(--scrim);')
|
||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||||
expect(css).toContain('--scrim:rgba(45,38,31,.38)')
|
||||
})
|
||||
|
||||
it('keeps compact continuous lists without per-row outer shadows', () => {
|
||||
@@ -409,14 +419,19 @@ describe('archived task-list disclosure', () => {
|
||||
})
|
||||
|
||||
describe('mobile sheet contract', () => {
|
||||
it('uses shared roles for details, creation and secondary actions', () => {
|
||||
expect(app).toContain('class="task-compose-mask app-sheet-mask"')
|
||||
expect(app).toContain('class="task-compose-sheet app-sheet app-sheet--create"')
|
||||
expect(app).toContain('class="more-sheet app-sheet app-sheet--actions"')
|
||||
expect(mvpPanel).toContain('class="task-compose-sheet habit-compose-sheet app-sheet app-sheet--create"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(countdownPanel).toContain('class="countdown-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(countdownPanel).toContain('class="countdown-modal app-sheet app-sheet--create"')
|
||||
it('defines a full-viewport overlay base and lets AppSheet override legacy detail translation', () => {
|
||||
expect(css).toContain('.app-overlay{position:fixed;inset:0;z-index:80;display:grid}')
|
||||
expect(css).toContain('.app-overlay>.detail{transform:none}')
|
||||
})
|
||||
|
||||
it('uses shared AppSheet variants for details and creation', () => {
|
||||
expect(app).toContain('panel-class="task-compose-sheet"')
|
||||
expect(app).toContain('variant="create"')
|
||||
expect(app).not.toContain('class="more-sheet app-sheet app-sheet--actions"')
|
||||
expect(mvpPanel).toContain('panel-class="task-compose-sheet habit-compose-sheet"')
|
||||
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||
expect(countdownPanel).toContain('panel-class="countdown-detail-sheet"')
|
||||
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||
expect(css).toContain('--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)')
|
||||
expect(css).toContain('.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim)')
|
||||
expect(css).toContain('.app-sheet__header{min-height:64px;')
|
||||
@@ -447,8 +462,7 @@ describe('mobile list row language', () => {
|
||||
|
||||
it('offers edit and archive on active detail, with permanent delete only on archived detail', () => {
|
||||
expect(mvpPanel).not.toContain('<button class="icon ghost" aria-label="归档习惯"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-mask app-sheet-mask"')
|
||||
expect(mvpPanel).toContain('class="habit-detail-sheet app-sheet app-sheet--detail"')
|
||||
expect(mvpPanel).toContain('panel-class="habit-detail-sheet"')
|
||||
expect(mvpPanel).toContain('@click="editHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain('@click="archiveHabit(selectedHabit)"')
|
||||
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
|
||||
@@ -466,8 +480,8 @@ describe('mobile list row language', () => {
|
||||
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
|
||||
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
|
||||
expect(mvpPanel).toContain('ref="habitDetailSheet"')
|
||||
expect(mvpPanel).toContain("habitDetailSheet.value?.focus()")
|
||||
expect(mvpPanel).toContain('initial-focus="button[aria-label=\'关闭习惯详情\']"')
|
||||
expect(mvpPanel).not.toContain('habitDetailSheet')
|
||||
})
|
||||
|
||||
it('provides an archived-habit viewing path', () => {
|
||||
@@ -927,7 +941,7 @@ describe('task and habit row decoration', () => {
|
||||
})
|
||||
|
||||
it('shows a password form with confirmation and calls the protected endpoint', () => {
|
||||
expect(mvpPanel).toContain('class="password-form"')
|
||||
expect(mvpPanel).toContain('class="password-form settings-form"')
|
||||
expect(mvpPanel).toContain('aria-label="当前密码"')
|
||||
expect(mvpPanel).toContain('aria-label="新密码"')
|
||||
expect(mvpPanel).toContain('aria-label="确认新密码"')
|
||||
@@ -972,20 +986,22 @@ describe('mobile touch targets', () => {
|
||||
})
|
||||
|
||||
describe('approved habit safety and U2 title hierarchy', () => {
|
||||
it('keeps one page title and upgrades settings card headings without changing the card class', () => {
|
||||
it('uses one page title and continuous settings section headings', () => {
|
||||
expect(mvpPanel).not.toContain('<h2>习惯</h2>')
|
||||
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
||||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
||||
expect(mvpPanel).toContain('<h2>修改密码</h2>')
|
||||
expect(mvpPanel).toContain('<h2>登录会话</h2>')
|
||||
expect(mvpPanel).toContain('<h2>最近活动</h2>')
|
||||
expect(css).toContain('.tool-card>h2{')
|
||||
for (const title of ['数据', '账户与安全', '登录设备', '活动', '危险操作']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
||||
expect(mvpPanel).toContain('class="settings-sections"')
|
||||
expect(mvpPanel).not.toContain('class="settings-grid"')
|
||||
expect(mvpPanel).not.toContain('class="tool-card')
|
||||
expect(css).toContain('.settings-sections{width:min(100%,760px);')
|
||||
expect(css).toContain('.settings-row{min-height:56px;')
|
||||
expect(css).toContain('.backup-preflight .danger-button{min-height:44px}')
|
||||
expect(css).toContain('.backup-preflight.invalid,.settings-danger{background:#fff2ef;')
|
||||
})
|
||||
|
||||
it('keeps invalid forms visible, disables save, and still shows the reason', () => {
|
||||
expect(app).toContain('const modalError = ref')
|
||||
expect(app).toContain('role="alert" class="field-error"')
|
||||
expect(app).toContain('normalizeRequiredName')
|
||||
expect(app).toContain('const appDialog = ref')
|
||||
expect(app).toContain('validate: label ? (value) => normalizeRequiredName(value).error : undefined')
|
||||
expect(mvpPanel).toContain('habitErrors.name')
|
||||
expect(mvpPanel).toContain('aria-describedby="habit-name-error"')
|
||||
expect(mvpPanel).toContain('const habitFormInvalid = computed')
|
||||
@@ -1009,17 +1025,26 @@ describe('approved habit safety and U2 title hierarchy', () => {
|
||||
})
|
||||
|
||||
describe('settings data tools', () => {
|
||||
it('keeps backup export and restore but removes the standalone import tool', () => {
|
||||
expect(mvpPanel).toContain('<h2>数据导出与恢复</h2>')
|
||||
expect(mvpPanel).toContain("fetch('/api/v1/export.csv'")
|
||||
expect(mvpPanel).toContain("'dodo-export.csv'")
|
||||
expect(mvpPanel).toContain('导出 CSV')
|
||||
expect(mvpPanel).not.toContain('导出 JSON')
|
||||
expect(mvpPanel).toContain('@click="restore"')
|
||||
expect(mvpPanel).not.toContain('<h3>导入</h3>')
|
||||
it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => {
|
||||
expect(mvpPanel).toContain('<h2>数据</h2>')
|
||||
expect(mvpPanel).toContain("downloadFullBackup()")
|
||||
expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
|
||||
expect(mvpPanel).toContain('导出 ZIP')
|
||||
expect(mvpPanel).toContain('accept=".zip,.csv,.json')
|
||||
expect(mvpPanel).toContain('v-model="restoreMode"')
|
||||
expect(mvpPanel).toContain('runPreflight')
|
||||
expect(mvpPanel).toContain('restorePreflight.valid')
|
||||
expect(mvpPanel).toContain('preflight_token')
|
||||
expect(mvpPanel).toContain('await restoreBackup')
|
||||
expect(mvpPanel).toContain("await uploadJson('/restore.csv?mode=merge'")
|
||||
expect(mvpPanel).toContain("await requestJson('/restore?mode=merge'")
|
||||
expect(mvpPanel).toContain('const snapshot = backupFileSnapshot(file, restoreMode.value)')
|
||||
expect(mvpPanel).toContain('shouldCommitBackupPreflight(generation, preflightGeneration, snapshot, restoreFile.value, restoreMode.value)')
|
||||
expect(mvpPanel).toContain('preflightController?.abort()')
|
||||
expect(mvpPanel).toContain("旧格式将在恢复时校验,不支持完整预检或 Replace。")
|
||||
expect(mvpPanel).toContain('<option v-if="!legacyRestore" value="replace">')
|
||||
expect(mvpPanel).toContain("emit('changed'); emit('notice', '数据已恢复')")
|
||||
expect(mvpPanel).not.toContain("request('/import/ticktick")
|
||||
expect(mvpPanel).not.toContain('importFile')
|
||||
expect(mvpPanel).not.toContain('importPreview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1132,8 +1157,8 @@ describe('unified floating add interaction', () => {
|
||||
expect(floatingAdd).toContain("emit('activate',")
|
||||
expect(css).toContain('.unified-fab.dragging')
|
||||
expect(css).toContain('.unified-fab.snapping')
|
||||
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
|
||||
expect(css).toContain('.countdown-compose-enter-active')
|
||||
expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
|
||||
expect(countdownPanel).toContain('panel-class="countdown-modal"')
|
||||
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
|
||||
})
|
||||
|
||||
@@ -1169,7 +1194,8 @@ describe('unified floating add interaction', () => {
|
||||
describe('desktop task detail disclosure', () => {
|
||||
it('gives the task list the full remaining width until a task is selected', () => {
|
||||
expect(app).toContain("'detail-open': Boolean(selectedTask)")
|
||||
expect(app).toContain('<aside v-if="selectedTask" class="detail"')
|
||||
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
|
||||
expect(app).toContain('panel-class="detail"')
|
||||
expect(app).toContain('@click="closeTaskDetail"')
|
||||
expect(app).toContain('function closeTaskDetail()')
|
||||
expect(app).not.toContain('<div v-else class="paper">')
|
||||
@@ -1233,8 +1259,8 @@ describe('sidebar information hierarchy', () => {
|
||||
it('groups folder and list editing actions into a clear compact hierarchy', () => {
|
||||
expect(app).toContain('aria-label="打开文件夹操作"')
|
||||
expect(app).toContain('aria-label="打开清单操作"')
|
||||
expect(app).toContain('class="sidebar-action-mask app-sheet-mask"')
|
||||
expect(app).toContain('class="sidebar-action-sheet app-sheet app-sheet--actions"')
|
||||
expect(app).toContain('panel-class="sidebar-action-sheet"')
|
||||
expect(app).toContain(':label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined"')
|
||||
expect(app).toContain('class="sidebar-action-kind"')
|
||||
expect(app).toContain('class="sidebar-action-group"')
|
||||
expect(app).toContain('class="sidebar-action-group-title"')
|
||||
@@ -1382,10 +1408,11 @@ describe('sidebar layout', () => {
|
||||
|
||||
it('uses a guarded custom confirmation that keeps failures visible', () => {
|
||||
expect(app).toContain('将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。')
|
||||
expect(app).toContain('ref="purgeCancelButton"')
|
||||
expect(app).toContain('purgeCancelButton.value?.focus()')
|
||||
expect(app).toContain('@keydown="handlePurgeDialogKeydown"')
|
||||
expect(app).toContain("if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()")
|
||||
expect(app).toContain('panel-class="purge-list-dialog"')
|
||||
expect(app).toContain('initial-focus=".secondary"')
|
||||
expect(app).toContain(':busy="purgeListSubmitting"')
|
||||
expect(app).toContain('@close="closePurgeList"')
|
||||
expect(app).not.toContain('handlePurgeDialogKeydown')
|
||||
expect(app).toContain('if (purgeListSubmitting.value) return')
|
||||
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'')
|
||||
expect(app).toContain(':disabled="purgeListSubmitting"')
|
||||
|
||||
Reference in New Issue
Block a user