feat: improve memo editing workflow
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import MemoEditor, { type Memo } from './MemoEditor.vue'
|
||||
import { createApp, h, nextTick, reactive } from 'vue'
|
||||
import MemoEditor, { type Memo, type MemoDraft } from './MemoEditor.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const memo: Memo = { id: 'm1', title: '旧标题', content: '正文', version: 3, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||
const draft: MemoDraft = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((yes) => { resolve = yes })
|
||||
return { promise, resolve }
|
||||
}
|
||||
async function flush() { await Promise.resolve(); await Promise.resolve(); await nextTick() }
|
||||
|
||||
async function mount(overrides: Record<string, unknown> = {}) {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
@@ -17,6 +24,104 @@ async function mount(overrides: Record<string, unknown> = {}) {
|
||||
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||
|
||||
describe('MemoEditor', () => {
|
||||
it('creates a local draft only on first save and locks click plus shortcut re-entry', async () => {
|
||||
const pending = deferred<Memo>()
|
||||
const request = vi.fn(() => pending.promise)
|
||||
const { host } = await mount({ memo: draft, request })
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(host.querySelector('.danger-text')).toBeNull()
|
||||
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = ' 新建标题 '; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||
save.click()
|
||||
host.querySelector('.memo-editor')!.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true }))
|
||||
save.click()
|
||||
await nextTick()
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '新建标题', content: '' }) })
|
||||
pending.resolve({ ...memo, id: 'new', title: '新建标题', content: '', version: 1 })
|
||||
await flush()
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('新建标题')
|
||||
})
|
||||
|
||||
it('keeps save entry points locked while a draft save is pending', async () => {
|
||||
const pending = deferred<Memo>()
|
||||
const request = vi.fn(() => pending.promise)
|
||||
const { host } = await mount({ memo: draft, request })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '保存中'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||
save.click()
|
||||
host.querySelector('.memo-editor')!.dispatchEvent(new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true }))
|
||||
save.click(); await nextTick()
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
expect(save.disabled).toBe(true)
|
||||
pending.resolve({ ...memo, id: 'new', title: '保存中', content: '', version: 1 })
|
||||
await flush()
|
||||
})
|
||||
|
||||
it('closes an untouched draft without confirmation or request but guards an edited draft', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const request = vi.fn()
|
||||
const clean = await mount({ memo: draft, request })
|
||||
clean.host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click(); await nextTick()
|
||||
expect(confirm).not.toHaveBeenCalled()
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(clean.events.close).toEqual([true])
|
||||
|
||||
const edited = await mount({ memo: draft, request })
|
||||
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()
|
||||
expect(edited.events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a failed draft payload and retries POST successfully', async () => {
|
||||
const request = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('创建失败'))
|
||||
.mockResolvedValueOnce({ ...memo, id: 'new', title: '重试标题', content: '正文', version: 1 })
|
||||
const { host } = await mount({ memo: draft, request })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
title.value = ' 重试标题 '; title.dispatchEvent(new Event('input'))
|
||||
body.value = '正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||
save.click(); await flush()
|
||||
expect(host.textContent).toContain('创建失败')
|
||||
expect(title.value).toBe(' 重试标题 ')
|
||||
expect(body.value).toBe('正文')
|
||||
expect(save.disabled).toBe(false)
|
||||
save.click(); await flush()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps a draft without offering reload when POST returns 409 and allows retry', async () => {
|
||||
const request = vi.fn()
|
||||
.mockRejectedValueOnce(Object.assign(new Error('同名备忘录无法创建'), { status: 409 }))
|
||||
.mockResolvedValueOnce({ ...memo, id: 'new', title: '重试草稿', content: '草稿正文', version: 1 })
|
||||
const { host } = await mount({ memo: draft, request })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
const body = host.querySelector<HTMLTextAreaElement>('[aria-label="备忘录正文"]')!
|
||||
title.value = '重试草稿'; title.dispatchEvent(new Event('input'))
|
||||
body.value = '草稿正文'; body.dispatchEvent(new Event('input')); await nextTick()
|
||||
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||
|
||||
save.click(); await flush()
|
||||
|
||||
expect(title.value).toBe('重试草稿')
|
||||
expect(body.value).toBe('草稿正文')
|
||||
expect(host.textContent).toContain('同名备忘录无法创建')
|
||||
expect(host.textContent).not.toContain('版本冲突')
|
||||
expect(host.querySelector('.memo-reload')).toBeNull()
|
||||
expect(save.disabled).toBe(false)
|
||||
|
||||
save.click(); await flush()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request).toHaveBeenLastCalledWith('/memos', { method: 'POST', body: JSON.stringify({ title: '重试草稿', content: '草稿正文' }) })
|
||||
})
|
||||
|
||||
it('requires a 1-200 character title and saves explicitly with its version', async () => {
|
||||
const { host, request, events } = await mount()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
@@ -47,6 +152,24 @@ describe('MemoEditor', () => {
|
||||
expect(host.querySelector('.memo-reload')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reports a current ordinary save failure and unlocks retry', async () => {
|
||||
const request = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('网络保存失败'))
|
||||
.mockResolvedValueOnce({ ...memo, title: '我的草稿', version: 4 })
|
||||
const { host } = await mount({ request })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '我的草稿'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
const save = host.querySelector<HTMLButtonElement>('.memo-save')!
|
||||
|
||||
save.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(host.textContent).toContain('网络保存失败')
|
||||
expect(title.value).toBe('我的草稿')
|
||||
expect(save.disabled).toBe(false)
|
||||
|
||||
save.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the editor open and reports destructive action failures', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const request = vi.fn(async (_path: string, options?: RequestInit): Promise<unknown> => {
|
||||
@@ -60,7 +183,7 @@ describe('MemoEditor', () => {
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('traps mobile focus, closes on Escape, and restores opener focus', async () => {
|
||||
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()
|
||||
const { host, events } = await mount({ mobile: true })
|
||||
const dialog = host.querySelector<HTMLElement>('.memo-editor')!
|
||||
@@ -74,10 +197,189 @@ describe('MemoEditor', () => {
|
||||
dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
|
||||
await nextTick()
|
||||
expect(events.close).toEqual([true])
|
||||
expect(document.activeElement).toBe(opener)
|
||||
expect(document.activeElement).not.toBe(opener)
|
||||
opener.remove()
|
||||
})
|
||||
|
||||
it('ignores a stale reload after its editor selection changes', async () => {
|
||||
const current = { ...memo, id: 'm2', title: '第二条', content: 'B 正文' }
|
||||
const props = reactive({ memo, selectionToken: 1 })
|
||||
const reload = deferred<Memo>()
|
||||
const conflictRequest = vi.fn().mockRejectedValueOnce(Object.assign(new Error('冲突'), { status: 409 })).mockImplementation(() => reload.promise)
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const saved = vi.fn()
|
||||
const app = createApp({
|
||||
data: () => props,
|
||||
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request: conflictRequest, onSaved: saved }) },
|
||||
})
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
const secondTitle = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
secondTitle.value = '冲突草稿'; secondTitle.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
host.querySelector<HTMLButtonElement>('.memo-reload')!.click()
|
||||
props.memo = current; props.selectionToken = 2; await nextTick()
|
||||
reload.resolve({ ...memo, title: 'A 迟到结果', content: 'A 迟到正文' }); await flush()
|
||||
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||
expect(saved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the current save success notice', async () => {
|
||||
const notice = vi.fn()
|
||||
const { host } = await mount({ onNotice: notice })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '当前保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
expect(notice).toHaveBeenCalledOnce()
|
||||
expect(notice).toHaveBeenCalledWith('备忘录已保存')
|
||||
})
|
||||
|
||||
it('silences a successful save after another editor selection is current', async () => {
|
||||
const pending = deferred<Memo>()
|
||||
const notice = vi.fn()
|
||||
const props = reactive({ memo, selectionToken: 1 })
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const app = createApp({
|
||||
data: () => props,
|
||||
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request: () => pending.promise, onNotice: notice }) },
|
||||
})
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '已提交的 A'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
props.memo = { ...memo, id: 'm2', title: '第二条' }; props.selectionToken = 2; await nextTick()
|
||||
pending.resolve({ ...memo, title: '已提交的 A', version: 4 }); await flush()
|
||||
|
||||
expect(notice).not.toHaveBeenCalled()
|
||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('第二条')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['remove', null, '.danger-text', '备忘录已移到回收站'],
|
||||
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||
])('silences stale %s success notices', async (_name, deletedAt, selector, message) => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const pending = deferred<Memo>()
|
||||
const notice = vi.fn()
|
||||
const props = reactive({ memo: { ...memo, deleted_at: deletedAt }, selectionToken: 1 })
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const app = createApp({
|
||||
data: () => props,
|
||||
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request: () => pending.promise, onNotice: notice }) },
|
||||
})
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>(selector)!.click()
|
||||
props.memo = { ...memo, id: 'm2', title: '第二条' }; props.selectionToken = 2; await nextTick()
|
||||
pending.resolve({ ...memo, deleted_at: null }); await flush()
|
||||
expect(notice).not.toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['remove', null, '.danger-text', '备忘录已移到回收站'],
|
||||
['restore', '2026-09-14T00:00:00Z', '.memo-editor footer .secondary', '备忘录已恢复'],
|
||||
['purge', '2026-09-14T00:00:00Z', '.danger-button', '备忘录已永久删除'],
|
||||
])('keeps the current %s success notice', async (_name, deletedAt, selector, message) => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const notice = vi.fn()
|
||||
const request = vi.fn().mockResolvedValue({ ...memo, deleted_at: null })
|
||||
const { host } = await mount({ memo: { ...memo, deleted_at: deletedAt }, request, onNotice: notice })
|
||||
host.querySelector<HTMLButtonElement>(selector)!.click(); await flush()
|
||||
expect(notice).toHaveBeenCalledOnce()
|
||||
expect(notice).toHaveBeenCalledWith(message)
|
||||
})
|
||||
|
||||
it('uses one lifecycle busy lock for double clicks and cross-operation entry', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const pending = deferred<Memo>()
|
||||
const request = vi.fn(() => pending.promise)
|
||||
const { host } = await mount({ memo: { ...memo, deleted_at: '2026-09-14T00:00:00Z' }, request })
|
||||
const restore = host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!
|
||||
const purge = host.querySelector<HTMLButtonElement>('.danger-button')!
|
||||
restore.click(); restore.click(); purge.click(); await nextTick()
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(restore.disabled).toBe(true)
|
||||
expect(purge.disabled).toBe(true)
|
||||
pending.resolve({ ...memo, deleted_at: null }); await flush()
|
||||
})
|
||||
|
||||
it('does not let an old lifecycle finally unlock a newer selection operation', async () => {
|
||||
const oldRestore = deferred<Memo>()
|
||||
const newRestore = deferred<Memo>()
|
||||
let calls = 0
|
||||
const request = vi.fn(() => (++calls === 1 ? oldRestore.promise : newRestore.promise))
|
||||
const props = reactive({ memo: { ...memo, deleted_at: '2026-09-14T00:00:00Z' }, selectionToken: 1 })
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const app = createApp({
|
||||
data: () => props,
|
||||
render() { return h(MemoEditor, { memo: this.memo, selectionToken: this.selectionToken, request }) },
|
||||
})
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||
props.memo = { ...memo, id: 'm2', title: '第二条', deleted_at: '2026-09-14T00:00:00Z' }
|
||||
props.selectionToken = 2
|
||||
await nextTick()
|
||||
const currentRestore = host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!
|
||||
expect(currentRestore.disabled).toBe(false)
|
||||
currentRestore.click(); await nextTick()
|
||||
oldRestore.resolve({ ...memo, deleted_at: null }); await flush()
|
||||
expect(currentRestore.disabled).toBe(true)
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
newRestore.resolve({ ...props.memo, deleted_at: null }); await flush()
|
||||
})
|
||||
|
||||
it('never sends draft lifecycle or reload requests with a null id', async () => {
|
||||
const request = vi.fn().mockRejectedValue(Object.assign(new Error('冲突'), { status: 409 }))
|
||||
const active = await mount({ memo: draft, request })
|
||||
const title = active.host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '草稿'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
active.host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
expect(active.host.querySelector('.memo-reload')).toBeNull()
|
||||
expect(request.mock.calls.map(([path]) => path)).toEqual(['/memos'])
|
||||
|
||||
const malformedDeletedDraft = { ...draft, deleted_at: '2026-09-14T00:00:00Z' } as unknown as MemoDraft
|
||||
const deleted = await mount({ memo: malformedDeletedDraft, request })
|
||||
deleted.host.querySelector<HTMLButtonElement>('.memo-editor footer .secondary')!.click()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
deleted.host.querySelector<HTMLButtonElement>('.danger-button')!.click(); await flush()
|
||||
expect(request.mock.calls.map(([path]) => path)).toEqual(['/memos'])
|
||||
})
|
||||
|
||||
it('keeps beforeunload protection during save and clears it after success', async () => {
|
||||
const pending = deferred<Memo>()
|
||||
const { host } = await mount({ request: () => pending.promise })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '保存中'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click()
|
||||
const during = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(during)
|
||||
expect(during.defaultPrevented).toBe(true)
|
||||
pending.resolve({ ...memo, title: '保存中', version: 4 }); await flush(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(host.querySelector<HTMLButtonElement>('.memo-save')!.disabled).toBe(true)
|
||||
const after = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(after)
|
||||
expect(after.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('emits save settlement after failure so its parent can release context', async () => {
|
||||
const settled = vi.fn()
|
||||
const request = vi.fn().mockRejectedValue(new Error('失败'))
|
||||
const { host } = await mount({ request, selectionToken: 7, onSaveFinished: settled })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '待保存'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||
expect(settled).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('emits lifecycle settlement after failure so its parent can release context', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const settled = vi.fn()
|
||||
const request = vi.fn().mockRejectedValue(new Error('删除失败'))
|
||||
const { host } = await mount({ request, selectionToken: 9, onLifecycleFinished: settled })
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click(); await flush()
|
||||
expect(settled).toHaveBeenCalledWith(9)
|
||||
})
|
||||
|
||||
it('guards dirty close and allows clean close', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { host, events } = await mount()
|
||||
|
||||
@@ -2,31 +2,38 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { ArchiveRestore, Trash2, X } from 'lucide-vue-next'
|
||||
|
||||
export type Memo = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
|
||||
export type Memo = MemoRecord
|
||||
export type MemoEditorValue = MemoRecord | MemoDraft
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ memo: Memo; request: RequestFn; mobile?: boolean }>()
|
||||
const emit = defineEmits<{ saved: [memo: Memo]; close: []; deleted: [id: string]; restored: [memo: Memo]; purged: [id: string]; notice: [message: string] }>()
|
||||
const props = withDefaults(defineProps<{ memo: MemoEditorValue; request: RequestFn; mobile?: boolean; selectionToken?: number }>(), { 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('')
|
||||
const saving = ref(false)
|
||||
const lifecycleBusy = ref(false)
|
||||
let lifecycleGeneration = 0
|
||||
const error = ref('')
|
||||
const conflict = ref(false)
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
let initial = { title: '', content: '' }
|
||||
let opener: HTMLElement | null = null
|
||||
const dirty = computed(() => title.value !== initial.title || content.value !== initial.content)
|
||||
const initial = ref({ title: '', content: '' })
|
||||
const dirty = computed(() => title.value !== initial.value.title || content.value !== initial.value.content)
|
||||
|
||||
function loadDraft(memo: Memo) {
|
||||
function loadDraft(memo: MemoEditorValue) {
|
||||
title.value = memo.title; content.value = memo.content
|
||||
initial = { title: memo.title, content: memo.content }
|
||||
error.value = ''; conflict.value = false
|
||||
initial.value = { title: memo.title, content: memo.content }
|
||||
saving.value = false; error.value = ''; conflict.value = false
|
||||
}
|
||||
watch(() => props.memo, loadDraft, { immediate: true })
|
||||
watch(() => props.selectionToken, () => {
|
||||
lifecycleGeneration += 1
|
||||
lifecycleBusy.value = false
|
||||
})
|
||||
function close() {
|
||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
emit('close')
|
||||
nextTick(() => opener?.focus())
|
||||
}
|
||||
function validate() {
|
||||
const value = title.value.trim()
|
||||
@@ -37,40 +44,104 @@ function validate() {
|
||||
async function save() {
|
||||
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
|
||||
saving.value = true; conflict.value = false
|
||||
const memoId = props.memo.id
|
||||
const version = props.memo.version
|
||||
const selectionToken = props.selectionToken
|
||||
const payload = { title: title.value.trim(), content: content.value }
|
||||
const isCurrentSelection = () => props.selectionToken === selectionToken
|
||||
emit('saveStarted', memoId, selectionToken)
|
||||
try {
|
||||
const updated = await props.request(`/memos/${props.memo.id}`, { method: 'PATCH', body: JSON.stringify({ title: title.value.trim(), content: content.value, version: props.memo.version }) }) as Memo
|
||||
loadDraft(updated); emit('saved', updated); emit('notice', '备忘录已保存')
|
||||
const updated = await props.request(memoId === null ? '/memos' : `/memos/${memoId}`, {
|
||||
method: memoId === null ? 'POST' : 'PATCH',
|
||||
body: JSON.stringify(memoId === null ? payload : { ...payload, version }),
|
||||
}) as MemoRecord
|
||||
if (isCurrentSelection()) {
|
||||
loadDraft(updated)
|
||||
emit('notice', memoId === null ? '备忘录已创建' : '备忘录已保存')
|
||||
}
|
||||
emit('saved', updated, selectionToken)
|
||||
} catch (reason) {
|
||||
if (!isCurrentSelection()) return
|
||||
const status = (reason as { status?: number }).status
|
||||
conflict.value = status === 409
|
||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : '保存失败'
|
||||
} finally { saving.value = false }
|
||||
conflict.value = memoId !== null && status === 409
|
||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? '创建失败' : '保存失败'
|
||||
} finally {
|
||||
emit('saveFinished', selectionToken)
|
||||
if (isCurrentSelection()) saving.value = false
|
||||
}
|
||||
}
|
||||
async function reload() {
|
||||
try { const fresh = await props.request(`/memos/${props.memo.id}`) as Memo; loadDraft(fresh); emit('saved', fresh) } catch (reason) { error.value = reason instanceof Error ? reason.message : '重新载入失败' }
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null) return
|
||||
const selectionToken = props.selectionToken
|
||||
const isCurrentSelection = () => props.memo.id === memoId && props.selectionToken === selectionToken
|
||||
try {
|
||||
const fresh = await props.request(`/memos/${memoId}`) as Memo
|
||||
if (!isCurrentSelection()) return
|
||||
loadDraft(fresh)
|
||||
emit('saved', fresh, selectionToken)
|
||||
} catch (reason) {
|
||||
if (isCurrentSelection()) error.value = reason instanceof Error ? reason.message : '重新载入失败'
|
||||
}
|
||||
}
|
||||
async function remove() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
const selectionToken = props.selectionToken
|
||||
const isCurrentOperation = () => props.selectionToken === selectionToken && lifecycleGeneration === operationToken
|
||||
emit('lifecycleStarted', memoId, selectionToken)
|
||||
try {
|
||||
await props.request(`/memos/${props.memo.id}`, { method: 'DELETE' })
|
||||
emit('deleted', props.memo.id); emit('notice', '备忘录已移到回收站')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '删除失败' }
|
||||
await props.request(`/memos/${memoId}`, { method: 'DELETE' })
|
||||
emit('deleted', memoId, selectionToken)
|
||||
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
|
||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '删除失败' }
|
||||
finally {
|
||||
emit('lifecycleFinished', selectionToken)
|
||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||
}
|
||||
}
|
||||
async function restore() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
const selectionToken = props.selectionToken
|
||||
const isCurrentOperation = () => props.selectionToken === selectionToken && lifecycleGeneration === operationToken
|
||||
emit('lifecycleStarted', memoId, selectionToken)
|
||||
try {
|
||||
const restored = await props.request(`/memos/${props.memo.id}/restore`, { method: 'POST' }) as Memo
|
||||
emit('restored', restored); emit('notice', '备忘录已恢复')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
|
||||
emit('restored', restored, selectionToken)
|
||||
if (isCurrentOperation()) emit('notice', '备忘录已恢复')
|
||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '恢复失败' }
|
||||
finally {
|
||||
emit('lifecycleFinished', selectionToken)
|
||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||
}
|
||||
}
|
||||
async function purge() {
|
||||
const memoId = props.memo.id
|
||||
if (memoId === null || lifecycleBusy.value) return
|
||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
||||
error.value = ''
|
||||
lifecycleBusy.value = true
|
||||
const operationToken = ++lifecycleGeneration
|
||||
const selectionToken = props.selectionToken
|
||||
const isCurrentOperation = () => props.selectionToken === selectionToken && lifecycleGeneration === operationToken
|
||||
emit('lifecycleStarted', memoId, selectionToken)
|
||||
try {
|
||||
await props.request(`/memos/${props.memo.id}/purge`, { method: 'DELETE' })
|
||||
emit('purged', props.memo.id); emit('notice', '备忘录已永久删除')
|
||||
} catch (reason) { error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
||||
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
|
||||
emit('purged', memoId, selectionToken)
|
||||
if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
|
||||
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '永久删除失败' }
|
||||
finally {
|
||||
emit('lifecycleFinished', selectionToken)
|
||||
if (isCurrentOperation()) lifecycleBusy.value = false
|
||||
}
|
||||
}
|
||||
function keydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); void save(); return }
|
||||
@@ -83,7 +154,7 @@ function keydown(event: KeyboardEvent) {
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||
}
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||
onMounted(() => { opener = document.activeElement as HTMLElement; window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||
onMounted(() => { window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
|
||||
defineExpose({ dirty, requestClose: close })
|
||||
</script>
|
||||
@@ -96,7 +167,7 @@ defineExpose({ dirty, requestClose: close })
|
||||
<label>正文<textarea v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…" :disabled="Boolean(memo.deleted_at)"></textarea></label>
|
||||
<p v-if="error" role="alert" class="memo-editor__error">{{ error }} <button v-if="conflict" class="memo-reload" type="button" @click="reload">重新载入</button></p>
|
||||
</div>
|
||||
<footer v-if="!memo.deleted_at"><button type="button" class="danger-text" @click="remove"><Trash2/>移到回收站</button><button type="button" class="primary-small memo-save" :disabled="saving || !dirty" @click="save">{{ saving ? '正在保存…' : '保存' }}</button></footer>
|
||||
<footer v-else><button type="button" class="secondary" @click="restore"><ArchiveRestore/>恢复</button><button type="button" class="danger-button" @click="purge"><Trash2/>永久删除</button></footer>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -10,8 +10,8 @@ afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
||||
describe('MemoRow', () => {
|
||||
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const selected: string[] = []
|
||||
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string) => selected.push(id) }))
|
||||
const selected: Array<{ id: string; opener: EventTarget | null }> = []
|
||||
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string, opener: EventTarget | null) => selected.push({ id, opener }) }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
const button = host.querySelector('button')!
|
||||
expect(button.classList.contains('active')).toBe(true)
|
||||
@@ -19,7 +19,8 @@ describe('MemoRow', () => {
|
||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
|
||||
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
||||
button.click()
|
||||
expect(selected).toEqual(['m1'])
|
||||
expect(selected).toHaveLength(1)
|
||||
expect(selected[0]).toEqual({ id: 'm1', opener: button })
|
||||
})
|
||||
|
||||
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
|
||||
@@ -39,4 +40,16 @@ describe('MemoRow', () => {
|
||||
await nextTick()
|
||||
expect(excerpt()).toBe('暂无正文')
|
||||
})
|
||||
|
||||
it('updates the displayed timestamp when the memo prop changes', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const current = ref({ ...memo })
|
||||
const app = createApp(() => h(MemoRow, { memo: current.value }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
const before = host.querySelector('time')?.textContent
|
||||
current.value = { ...current.value, updated_at: '2026-10-20T10:30:00Z' }
|
||||
await nextTick()
|
||||
expect(host.querySelector('time')?.textContent).not.toBe(before)
|
||||
expect(host.querySelector('time')?.getAttribute('datetime')).toBe('2026-10-20T10:30:00Z')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,13 @@ import { computed } from 'vue'
|
||||
|
||||
export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
|
||||
const emit = defineEmits<{ select: [id: string] }>()
|
||||
const emit = defineEmits<{ select: [id: string, opener: EventTarget | null] }>()
|
||||
const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim())
|
||||
const updated = new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at))
|
||||
const updated = computed(() => new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id)">
|
||||
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id, $event.currentTarget)">
|
||||
<strong>{{ memo.title }}</strong>
|
||||
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
|
||||
<time :datetime="memo.updated_at">{{ updated }}</time>
|
||||
|
||||
Reference in New Issue
Block a user