Files
dodo/frontend/src/components/MemoEditor.vue
T
bboysoul 6c234d7d82
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s
feat: strengthen backup and mobile workflows
2026-09-16 21:12:52 +08:00

210 lines
12 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
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 = 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('')
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 initial = ref({ title: '', content: '' })
const memoPreview = ref(false)
const memoBodyEditor = ref<HTMLTextAreaElement | null>(null)
const dirty = computed(() => title.value !== initial.value.title || content.value !== initial.value.content)
function loadDraft(memo: MemoEditorValue) {
title.value = memo.title; content.value = memo.content
initial.value = { title: memo.title, content: memo.content }
memoPreview.value = Boolean(memo.deleted_at)
saving.value = false; error.value = ''; conflict.value = false
}
watch(() => props.memo, loadDraft, { immediate: true })
watch(() => props.selectionToken, () => {
lifecycleGeneration += 1
lifecycleBusy.value = false
})
async function close() {
if (dirty.value && !(await props.confirmAction?.({ title: '放弃未保存的更改?', description: '关闭后,当前草稿不会保存。', confirmText: '放弃更改', danger: true }))) return
emit('close')
}
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
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(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 = 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() {
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 (!(await props.confirmAction?.({ title: `把“${props.memo.title}”移到回收站?`, description: '之后可以在回收站恢复。', confirmText: '移到回收站', danger: true }))) 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/${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/${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 (!(await props.confirmAction?.({ title: `永久删除“${props.memo.title}”?`, description: '此操作无法撤销。', confirmText: '永久删除', danger: true }))) 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/${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 formatMemoBody(format: MarkdownFormat) {
if (props.memo.deleted_at) return
const editor = memoBodyEditor.value
const start = editor?.selectionStart ?? content.value.length
const end = editor?.selectionEnd ?? start
const formatted = applyMarkdownFormat(content.value, start, end, format)
content.value = formatted.value
memoPreview.value = false
void nextTick(() => {
const target = memoBodyEditor.value
if (!target) return
target.focus()
target.setSelectionRange(formatted.start, formatted.end)
})
}
function handleMemoBodyShortcut(event: KeyboardEvent) {
if (!(event.metaKey || event.ctrlKey)) return
const format = event.key.toLowerCase() === 'b' ? 'bold' : event.key.toLowerCase() === 'i' ? 'italic' : event.key.toLowerCase() === 'k' ? 'link' : null
if (!format) return
event.preventDefault()
formatMemoBody(format)
}
function keydown(event: KeyboardEvent) {
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()) })
onUnmounted(() => window.removeEventListener('beforeunload', beforeUnload))
defineExpose({ dirty, requestClose: close })
</script>
<template>
<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>
<div class="memo-field memo-markdown-field">
<div class="memo-field__label"><span>正文</span><span v-if="!memo.deleted_at" class="markdown-mode-switch"><button type="button" :aria-pressed="!memoPreview" :class="{active:!memoPreview}" @click="memoPreview=false">编辑</button><button type="button" :aria-pressed="memoPreview" :class="{active:memoPreview}" @click="memoPreview=true">预览</button></span></div>
<div v-if="!memoPreview && !memo.deleted_at" class="markdown-editor-shell memo-markdown-editor">
<div class="markdown-toolbar" role="toolbar" aria-label="备忘录 Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatMemoBody('heading')"><Heading2/></button>
<button type="button" aria-label="粗体" title="粗体" @click="formatMemoBody('bold')"><Bold/></button>
<button type="button" aria-label="斜体" title="斜体" @click="formatMemoBody('italic')"><Italic/></button>
<button type="button" aria-label="无序列表" title="无序列表" @click="formatMemoBody('bullet')"><List/></button>
<button type="button" aria-label="有序列表" title="有序列表" @click="formatMemoBody('ordered')"><ListOrdered/></button>
<button type="button" aria-label="待办" title="待办" @click="formatMemoBody('task')"><ListChecks/></button>
<button type="button" aria-label="链接" title="链接" @click="formatMemoBody('link')"><Link/></button>
<button type="button" aria-label="行内代码" title="行内代码" @click="formatMemoBody('code')"><Code/></button>
<button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatMemoBody('codeblock')">{ }</button>
<button type="button" aria-label="引用" title="引用" @click="formatMemoBody('quote')"><Quote/></button>
</div>
<textarea ref="memoBodyEditor" v-model="content" rows="16" aria-label="备忘录正文" placeholder="写点什么…支持 Markdown" @keydown="handleMemoBodyShortcut"/>
</div>
<div v-else class="markdown-preview memo-markdown-preview" :class="{'markdown-preview-empty':!content.trim()}" role="region" aria-label="备忘录 Markdown 预览" v-html="content.trim() ? renderMarkdown(content) : '<p>暂无正文,切回编辑开始书写。</p>'"/>
</div>
<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 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>
</div>
</template>