feat: add standalone memos
ci / gitleaks (push) Successful in 10s
ci / docker (push) Successful in 3m38s

This commit is contained in:
2026-09-12 10:22:35 +08:00
parent e07a1de1f5
commit 31c2218edc
18 changed files with 1063 additions and 13 deletions
@@ -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])
})
})
+102
View File
@@ -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>
+42
View File
@@ -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('暂无正文')
})
})
+17
View File
@@ -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>