feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+115
View File
@@ -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)
})
+29
View File
@@ -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 }
+37
View File
@@ -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()
}
}
+112
View File
@@ -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)
})
+118
View File
@@ -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
View File
@@ -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"]}}
+41
View File
@@ -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',
},
})
+36
View File
@@ -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}`)))
})))
+15
View File
@@ -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
View File
@@ -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>
+127 -12
View File
@@ -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')
})
})
+69 -63
View File
@@ -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>
+11 -2
View File
@@ -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'")
})
})
+40 -21
View File
@@ -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)
+18 -6
View File
@@ -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
View File
@@ -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>
+70
View File
@@ -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' }))
})
})
+28
View File
@@ -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 })
}
+31
View File
@@ -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
}
}
+48
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
export * from './errors'
export * from './http'
export * from './backups'
+58
View File
@@ -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>
+262
View File
@@ -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'])
})
})
+83
View File
@@ -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()")
})
})
+1 -1
View File
@@ -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
+21 -27
View File
@@ -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 -16
View File
@@ -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')
}
-15
View File
@@ -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()
})
})
-6
View File
@@ -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
}
+2 -2
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+75 -48
View File
@@ -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"')