feat: improve memo editing workflow
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user