feat: improve memo editing workflow
ci / gitleaks (push) Successful in 45s
ci / docker (push) Successful in 7m16s

This commit is contained in:
2026-09-14 17:59:23 +08:00
parent 31c2218edc
commit 4e8045159f
12 changed files with 1398 additions and 90 deletions
+144 -27
View File
@@ -2,11 +2,11 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Archive, FileText, Search } from 'lucide-vue-next'
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type Memo } from './components/MemoEditor.vue'
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ request: RequestFn }>()
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash'] }>()
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash']; detail: [open: boolean] }>()
const scope = ref<'active' | 'trash'>('active')
const query = ref('')
const items = ref<MemoListItem[]>([])
@@ -15,57 +15,174 @@ const page = ref(1)
const loading = ref(false)
const refreshing = ref(false)
const error = ref('')
const selected = ref<Memo | null>(null)
const selected = ref<MemoEditorValue | null>(null)
const selectedToken = ref(0)
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
const searchInput = ref<HTMLInputElement | null>(null)
const mobileDetail = ref(window.innerWidth <= 930)
let detailOpener: HTMLElement | null = null
let timer: number | undefined
let generation = 0
const hasMore = computed(() => items.value.length < total.value)
let committedGeneration = 0
let detailGeneration = 0
type MemoCriteria = { scope: 'active' | 'trash'; query: string }
const saveContexts = new Map<number, { criteria: MemoCriteria; generation: number }>()
const lifecycleContexts = new Map<number, { criteria: MemoCriteria; generation: number }>()
function currentCriteria(): MemoCriteria { return { scope: scope.value, query: query.value.trim() } }
function criteriaEqual(left: MemoCriteria, right: MemoCriteria) { return left.scope === right.scope && left.query === right.query }
const committedCriteria = ref<MemoCriteria>(currentCriteria())
const criteriaMatch = computed(() => criteriaEqual(committedCriteria.value, currentCriteria()))
const hasMore = computed(() => criteriaMatch.value && items.value.length < total.value)
const emptyCopy = computed(() => query.value.trim() ? '没有匹配的备忘录' : scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
async function load(reset = true) {
const token = ++generation
if (reset) { page.value = 1; loading.value = true } else refreshing.value = true
const criteria = currentCriteria()
const requestedPage = reset ? 1 : page.value
if (reset) loading.value = true
else refreshing.value = true
error.value = ''
try {
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(page.value), page_size: '50' })
const params = new URLSearchParams({ scope: criteria.scope, q: criteria.query, page: String(requestedPage), page_size: '50' })
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
if (token !== generation) return
if (token !== generation || !criteriaEqual(criteria, currentCriteria())) return
items.value = reset ? data.items : [...items.value, ...data.items]
total.value = data.total
page.value = requestedPage
if (reset) {
committedCriteria.value = criteria
committedGeneration = token
}
} catch (reason) {
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = reason instanceof Error ? reason.message : '加载失败'
} finally {
if (token === generation && criteriaEqual(criteria, currentCriteria())) {
loading.value = false
refreshing.value = false
}
}
}
async function loadMore() {
if (loading.value || !hasMore.value || refreshing.value) return
const nextPage = page.value + 1
const token = ++generation
refreshing.value = true
error.value = ''
try {
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(nextPage), page_size: '50' })
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
if (token !== generation) return
items.value = [...items.value, ...data.items]
total.value = data.total
page.value = nextPage
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
finally { if (token === generation) { loading.value = false; refreshing.value = false } }
finally { if (token === generation) refreshing.value = false }
}
function closeDetail() {
detailGeneration += 1
selectedToken.value = detailGeneration
selected.value = null
emit('detail', false)
const opener = detailOpener
detailOpener = null
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
}
async function loadMore() { if (!hasMore.value || refreshing.value) return; page.value += 1; await load(false) }
async function setScope(next: 'active' | 'trash') {
if (next === scope.value) return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
selected.value = null; scope.value = next; emit('scope', next); await load()
closeDetail(); scope.value = next; emit('scope', next); await load()
}
async function selectMemo(id: string) {
async function selectMemo(id: string, opener?: EventTarget | null) {
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定切换吗?')) return
try { selected.value = await props.request(`/memos/${id}`) as Memo } catch (reason) { error.value = reason instanceof Error ? reason.message : '读取失败' }
if (opener instanceof HTMLElement) detailOpener = opener
const token = ++detailGeneration
try {
const memo = await props.request(`/memos/${id}`) as MemoRecord
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
} catch (reason) {
if (token === detailGeneration) error.value = reason instanceof Error ? reason.message : '读取失败'
}
}
async function createMemo() {
if (scope.value === 'trash') return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) return
try {
const created = await props.request('/memos', { method: 'POST', body: JSON.stringify({ title: '未命名备忘录', content: '' }) }) as Memo
items.value = [{ ...created, excerpt: '' }, ...items.value]; total.value += 1; selected.value = created
emit('notice', '备忘录已创建')
} catch (reason) { error.value = reason instanceof Error ? reason.message : '创建失败' }
const token = ++detailGeneration
selectedToken.value = token
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
detailOpener = searchInput.value
emit('detail', true)
}
function updateItem(memo: Memo) {
selected.value = memo
function memoExcerpt(content: string) {
const collapsed = content.replace(/\s+/g, ' ').trim()
return collapsed.length <= 120 ? collapsed : `${collapsed.slice(0, 119)}`
}
function matchesCriteria(memo: MemoRecord, criteria: MemoCriteria) {
if (criteria.scope !== 'active') return false
const needle = criteria.query.toLocaleLowerCase()
return !needle || memo.title.toLocaleLowerCase().includes(needle) || memo.content.toLocaleLowerCase().includes(needle)
}
function compareMemos(left: MemoListItem, right: MemoListItem) {
const updated = Date.parse(right.updated_at) - Date.parse(left.updated_at)
return updated || right.id.localeCompare(left.id)
}
function beginSave(_id: string | null, selectionToken: number) {
saveContexts.set(selectionToken, { criteria: { ...committedCriteria.value }, generation: committedGeneration })
}
function finishSave(selectionToken: number) { saveContexts.delete(selectionToken) }
function beginLifecycle(_id: string, selectionToken: number) {
lifecycleContexts.set(selectionToken, { criteria: { ...committedCriteria.value }, generation: committedGeneration })
}
function finishLifecycle(selectionToken: number) { lifecycleContexts.delete(selectionToken) }
function updateItem(memo: MemoRecord, selectionToken: number) {
if (selectionToken === selectedToken.value) selected.value = memo
const saveContext = saveContexts.get(selectionToken)
if (!saveContext || saveContext.generation !== committedGeneration || !criteriaEqual(saveContext.criteria, committedCriteria.value)) return
const index = items.value.findIndex((item) => item.id === memo.id)
if (index >= 0) items.value[index] = { ...memo, excerpt: memo.content.replace(/\s+/g, ' ').trim().slice(0, 120) }
if (!matchesCriteria(memo, saveContext.criteria)) {
if (index >= 0) {
items.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
return
}
const listItem = { ...memo, excerpt: memoExcerpt(memo.content) }
if (index < 0) {
items.value.push(listItem)
total.value += 1
} else items.value[index] = listItem
items.value.sort(compareMemos)
}
function removeItem(id: string) { items.value = items.value.filter((item) => item.id !== id); total.value = Math.max(0, total.value - 1); selected.value = null }
function removeRestoredItem(memo: Memo) { removeItem(memo.id) }
function removeItem(id: string, selectionToken: number) {
const context = lifecycleContexts.get(selectionToken)
lifecycleContexts.delete(selectionToken)
if (context && context.generation === committedGeneration && criteriaEqual(context.criteria, committedCriteria.value)) {
const index = items.value.findIndex((item) => item.id === id)
if (index >= 0) {
items.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
}
if (selectionToken !== selectedToken.value) return
detailGeneration += 1
selectedToken.value = detailGeneration
selected.value = null
emit('detail', false)
const opener = detailOpener
detailOpener = null
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
}
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
function updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
watch(query, () => { if (timer) clearTimeout(timer); timer = window.setTimeout(() => void load(), 250) })
watch(query, () => {
generation += 1
loading.value = false
refreshing.value = false
error.value = ''
if (timer) clearTimeout(timer)
timer = window.setTimeout(() => void load(), 250)
})
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
onUnmounted(() => { window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
onUnmounted(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
</script>
@@ -74,7 +191,7 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
<div class="memo-panel__main" :inert="selected && mobileDetail ? true : undefined">
<div class="memo-toolbar">
<div class="memo-scope" role="tablist" aria-label="备忘录范围"><button role="tab" data-scope="active" :aria-selected="scope==='active'" @click="setScope('active')">活动</button><button role="tab" data-scope="trash" :aria-selected="scope==='trash'" @click="setScope('trash')"><Archive/>回收站</button></div>
<label class="memo-search"><Search/><input v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
<label class="memo-search"><Search/><input ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…"></label>
</div>
<p v-if="error" class="memo-error" role="alert">{{error}} <button class="link" @click="load()">重试</button></p>
<div v-if="loading && !items.length" class="memo-state"><span class="loader"/>正在载入备忘录</div>
@@ -82,9 +199,9 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
<div v-else class="memo-list" :class="{refreshing}" aria-live="polite">
<MemoRow v-for="memo in items" :key="memo.id" :memo="memo" :active="selected?.id===memo.id" @select="selectMemo"/>
</div>
<button v-if="hasMore" class="secondary memo-load-more" :disabled="refreshing" @click="loadMore">{{refreshing?'正在加载':'加载更多'}}</button>
<button v-if="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载':'加载更多'}}</button>
</div>
<div v-if="selected" class="memo-editor-scrim" @click="editor?.requestClose()"/>
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" @saved="updateItem" @close="selected=null" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" @save-started="beginSave" @save-finished="finishSave" @lifecycle-started="beginLifecycle" @lifecycle-finished="finishLifecycle" @saved="updateItem" @close="closeDetail" @deleted="removeItem" @restored="removeRestoredItem" @purged="removeItem" @notice="emit('notice',$event)"/>
</section>
</template>