feat: add standalone memos
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
|
||||
|
||||
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' })
|
||||
const props = withDefaults(defineProps<{ label?: string; show?: boolean }>(), { label: '添加', show: true })
|
||||
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
|
||||
const dragging = ref(false)
|
||||
const snapping = ref(false)
|
||||
@@ -115,7 +115,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
||||
<button v-if="props.show" class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
||||
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g>
|
||||
<g class="fab-cat__character">
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import MemoEditor, { type Memo } 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 }
|
||||
|
||||
async function mount(overrides: Record<string, unknown> = {}) {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
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 }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
return { host, request: request as ReturnType<typeof vi.fn>, events }
|
||||
}
|
||||
afterEach(() => { vi.restoreAllMocks(); cleanups.splice(0).forEach((cleanup) => cleanup()) })
|
||||
|
||||
describe('MemoEditor', () => {
|
||||
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="备忘录标题"]')!
|
||||
title.value = ' 新标题 '; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await nextTick(); await nextTick()
|
||||
expect(request).toHaveBeenCalledWith('/memos/m1', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: '新标题', content: '正文', version: 3 }) }))
|
||||
expect(events.saved).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('supports Cmd/Ctrl+S and prevents browser save', async () => {
|
||||
const { host, request } = await mount()
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '新标题'; title.dispatchEvent(new Event('input'))
|
||||
const event = new KeyboardEvent('keydown', { key: 's', metaKey: true, bubbles: true, cancelable: true })
|
||||
host.querySelector('.memo-editor')!.dispatchEvent(event); await nextTick(); await nextTick()
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(request).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the draft and offers reload after a 409 conflict', async () => {
|
||||
const request = vi.fn().mockRejectedValue(Object.assign(new Error('备忘录版本冲突'), { status: 409 }))
|
||||
const { host } = await mount({ request })
|
||||
const title = host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')!
|
||||
title.value = '我的草稿'; title.dispatchEvent(new Event('input')); await nextTick()
|
||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(title.value).toBe('我的草稿')
|
||||
expect(host.textContent).toContain('版本冲突')
|
||||
expect(host.querySelector('.memo-reload')).not.toBeNull()
|
||||
})
|
||||
|
||||
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> => {
|
||||
if (options?.method === 'DELETE') throw new Error('删除失败')
|
||||
return memo
|
||||
})
|
||||
const { host, events } = await mount({ request })
|
||||
host.querySelector<HTMLButtonElement>('.danger-text')!.click()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); await nextTick()
|
||||
expect(host.textContent).toContain('删除失败')
|
||||
expect(events.close).toEqual([])
|
||||
})
|
||||
|
||||
it('traps mobile focus, closes on Escape, and restores opener focus', 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')!
|
||||
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 }))
|
||||
await nextTick()
|
||||
expect(events.close).toEqual([true])
|
||||
expect(document.activeElement).toBe(opener)
|
||||
opener.remove()
|
||||
})
|
||||
|
||||
it('guards dirty close and allows clean close', async () => {
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { host, events } = await mount()
|
||||
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()
|
||||
expect(events.close).toEqual([])
|
||||
confirm.mockReturnValue(true)
|
||||
host.querySelector<HTMLButtonElement>('[aria-label="关闭备忘录"]')!.click()
|
||||
expect(events.close).toEqual([true])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
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 }
|
||||
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 title = ref('')
|
||||
const content = ref('')
|
||||
const saving = ref(false)
|
||||
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)
|
||||
|
||||
function loadDraft(memo: Memo) {
|
||||
title.value = memo.title; content.value = memo.content
|
||||
initial = { title: memo.title, content: memo.content }
|
||||
error.value = ''; conflict.value = false
|
||||
}
|
||||
watch(() => props.memo, loadDraft, { immediate: true })
|
||||
function close() {
|
||||
if (dirty.value && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
emit('close')
|
||||
nextTick(() => opener?.focus())
|
||||
}
|
||||
function validate() {
|
||||
const value = title.value.trim()
|
||||
if (!value) return '请输入标题'
|
||||
if (value.length > 200) return '标题最多 200 个字符'
|
||||
return ''
|
||||
}
|
||||
async function save() {
|
||||
error.value = validate(); if (error.value || saving.value || props.memo.deleted_at) return
|
||||
saving.value = true; conflict.value = false
|
||||
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', '备忘录已保存')
|
||||
} catch (reason) {
|
||||
const status = (reason as { status?: number }).status
|
||||
conflict.value = status === 409
|
||||
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : '保存失败'
|
||||
} finally { 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 : '重新载入失败' }
|
||||
}
|
||||
async function remove() {
|
||||
if (!window.confirm(`把“${props.memo.title}”移到回收站?`)) return
|
||||
error.value = ''
|
||||
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 : '删除失败' }
|
||||
}
|
||||
async function restore() {
|
||||
error.value = ''
|
||||
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 : '恢复失败' }
|
||||
}
|
||||
async function purge() {
|
||||
if (!window.confirm(`永久删除“${props.memo.title}”?此操作无法撤销。`)) return
|
||||
error.value = ''
|
||||
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 : '永久删除失败' }
|
||||
}
|
||||
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() }
|
||||
}
|
||||
function beforeUnload(event: BeforeUnloadEvent) { if (dirty.value) event.preventDefault() }
|
||||
onMounted(() => { opener = document.activeElement as HTMLElement; window.addEventListener('beforeunload', beforeUnload); nextTick(() => titleInput.value?.focus()) })
|
||||
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createApp, h, nextTick, ref } from 'vue'
|
||||
import MemoRow from './MemoRow.vue'
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
const memo = { id: 'm1', title: '旅行清单', excerpt: '护照\n 充电器 相机', version: 1, created_at: '2026-09-12T01:00:00Z', updated_at: '2026-09-12T02:00:00Z', deleted_at: null }
|
||||
|
||||
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) }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
const button = host.querySelector('button')!
|
||||
expect(button.classList.contains('active')).toBe(true)
|
||||
expect(button.getAttribute('aria-current')).toBe('true')
|
||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
|
||||
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
||||
button.click()
|
||||
expect(selected).toEqual(['m1'])
|
||||
})
|
||||
|
||||
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
|
||||
const host = document.createElement('div'); document.body.append(host)
|
||||
const current = ref({ ...memo, excerpt: '' })
|
||||
const app = createApp(() => h(MemoRow, { memo: current.value }))
|
||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
||||
|
||||
const excerpt = () => host.querySelector('.memo-row__excerpt')?.textContent
|
||||
expect(excerpt()).toBe('暂无正文')
|
||||
|
||||
current.value = { ...current.value, excerpt: '保存后\n 新摘要 正常' }
|
||||
await nextTick()
|
||||
expect(excerpt()).toBe('保存后 新摘要 正常')
|
||||
|
||||
current.value = { ...current.value, excerpt: ' \n\t ' }
|
||||
await nextTick()
|
||||
expect(excerpt()).toBe('暂无正文')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
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 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))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id)">
|
||||
<strong>{{ memo.title }}</strong>
|
||||
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
|
||||
<time :datetime="memo.updated_at">{{ updated }}</time>
|
||||
</button>
|
||||
</template>
|
||||
Reference in New Issue
Block a user