refactor: remove manual refresh and search controls
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m17s

This commit is contained in:
2026-09-19 21:15:23 +08:00
parent 0cf1c49094
commit 4973d56a64
29 changed files with 244 additions and 860 deletions
+15 -36
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Archive, FileText, Search, X } from 'lucide-vue-next'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { Archive, FileText } from 'lucide-vue-next'
import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
@@ -10,7 +10,6 @@ type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ request: RequestFn }>()
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[]>([])
const total = ref(0)
const page = ref(1)
@@ -21,25 +20,21 @@ const selected = ref<MemoEditorValue | null>(null)
const selectedToken = ref(0)
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
const searchInput = ref<HTMLInputElement | null>(null)
const searchToggle = ref<HTMLButtonElement | null>(null)
const mobileLayout = ref(window.innerWidth <= 930)
const mobileDetail = ref(mobileLayout.value)
const mobileSearchOpen = ref(false)
let detailOpener: HTMLElement | null = null
let timer: number | undefined
let generation = 0
let committedGeneration = 0
let detailGeneration = 0
type MemoCriteria = { scope: 'active' | 'trash'; query: string }
type MemoCriteria = { scope: 'active' | 'trash' }
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 }
function currentCriteria(): MemoCriteria { return { scope: scope.value } }
function criteriaEqual(left: MemoCriteria, right: MemoCriteria) { return left.scope === right.scope }
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' ? '回收站是空的' : '还没有备忘录')
const emptyCopy = computed(() => scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
async function load(reset = true) {
const token = ++generation
@@ -49,7 +44,7 @@ async function load(reset = true) {
else refreshing.value = true
error.value = ''
try {
const params = new URLSearchParams({ scope: criteria.scope, q: criteria.query, page: String(requestedPage), page_size: '50' })
const params = new URLSearchParams({ scope: criteria.scope, page: String(requestedPage), page_size: '50' })
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
if (token !== generation || !criteriaEqual(criteria, currentCriteria())) return
items.value = reset ? data.items : [...items.value, ...data.items]
@@ -75,7 +70,7 @@ async function loadMore() {
refreshing.value = true
error.value = ''
try {
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(nextPage), page_size: '50' })
const params = new URLSearchParams({ scope: scope.value, 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]
@@ -91,7 +86,7 @@ function closeDetail() {
emit('detail', false)
const opener = detailOpener
detailOpener = null
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
void nextTick(() => opener?.isConnected && opener.focus())
}
function showConfirm(options: AppDialogOptions) {
return appDialog.value?.show(options).then((result) => result === true) ?? Promise.resolve(false)
@@ -122,13 +117,11 @@ async function createMemo() {
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
detailOpener = null
emit('detail', true)
}
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 matchesCriteria(_memo: MemoRecord, criteria: MemoCriteria) {
return criteria.scope === 'active'
}
function compareMemos(left: MemoListItem, right: MemoListItem) {
const updated = Date.parse(right.updated_at) - Date.parse(left.updated_at)
@@ -178,27 +171,15 @@ function removeItem(id: string, selectionToken: number) {
emit('detail', false)
const opener = detailOpener
detailOpener = null
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.focus())
void nextTick(() => opener?.isConnected && opener.focus())
}
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
function openMobileSearch() { mobileSearchOpen.value = true; void nextTick(() => searchInput.value?.focus()) }
function closeMobileSearch() { mobileSearchOpen.value = false; void nextTick(() => searchToggle.value?.focus()) }
function handleSearchKeydown(event: KeyboardEvent) { if (event.key === 'Escape' && mobileLayout.value) { event.preventDefault(); closeMobileSearch() } }
function clearSearch() { query.value = ''; void nextTick(() => searchInput.value?.focus()) }
function updateLayout() {
mobileLayout.value = window.innerWidth <= 930
mobileDetail.value = mobileLayout.value
}
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(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout); if (timer) clearTimeout(timer) })
onUnmounted(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout) })
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
</script>
@@ -207,12 +188,10 @@ 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>
<button ref="searchToggle" class="memo-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索备忘录' : '展开搜索备忘录'" :aria-expanded="mobileLayout ? mobileSearchOpen : true" aria-controls="memo-search-panel" @click="mobileSearchOpen ? closeMobileSearch() : openMobileSearch()"><Search/></button>
<div id="memo-search-panel" class="memo-search-panel" :class="{'is-open':mobileSearchOpen}" :hidden="mobileLayout && !mobileSearchOpen"><label class="memo-search"><Search/><input id="memo-search-input" ref="searchInput" v-model="query" aria-label="搜索备忘录" placeholder="搜索标题或正文…" @keydown="handleSearchKeydown"></label><button v-if="query" type="button" class="memo-search-clear" aria-label="清空搜索" @click="clearSearch"><X/></button></div>
</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>
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{query ? '换个关键词试试' : scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
<div v-else-if="!items.length" class="memo-state"><FileText/><b>{{emptyCopy}}</b><span>{{scope==='trash' ? '删除的备忘录会显示在这里' : '点击右下角添加按钮新建一条'}}</span></div>
<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>