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
+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>