feat: add standalone memos
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
|
||||
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
|
||||
const props = defineProps<{ request: RequestFn }>()
|
||||
const emit = defineEmits<{ notice: [message: string]; scope: [scope: 'active' | 'trash'] }>()
|
||||
const scope = ref<'active' | 'trash'>('active')
|
||||
const query = ref('')
|
||||
const items = ref<MemoListItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const error = ref('')
|
||||
const selected = ref<Memo | null>(null)
|
||||
const editor = ref<InstanceType<typeof MemoEditor> | null>(null)
|
||||
const mobileDetail = ref(window.innerWidth <= 930)
|
||||
let timer: number | undefined
|
||||
let generation = 0
|
||||
const hasMore = computed(() => 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
|
||||
error.value = ''
|
||||
try {
|
||||
const params = new URLSearchParams({ scope: scope.value, q: query.value.trim(), page: String(page.value), page_size: '50' })
|
||||
const data = await props.request(`/memos?${params}`) as { items: MemoListItem[]; total: number }
|
||||
if (token !== generation) return
|
||||
items.value = reset ? data.items : [...items.value, ...data.items]
|
||||
total.value = data.total
|
||||
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' }
|
||||
finally { if (token === generation) { loading.value = false; refreshing.value = false } }
|
||||
}
|
||||
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()
|
||||
}
|
||||
async function selectMemo(id: string) {
|
||||
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 : '读取失败' }
|
||||
}
|
||||
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 : '创建失败' }
|
||||
}
|
||||
function updateItem(memo: Memo) {
|
||||
selected.value = memo
|
||||
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) }
|
||||
}
|
||||
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 updateLayout() { mobileDetail.value = window.innerWidth <= 930 }
|
||||
watch(query, () => { 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) })
|
||||
defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dirty: computed(() => Boolean(editor.value?.dirty)) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="memo-panel" :class="{'memo-panel--detail':selected}">
|
||||
<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>
|
||||
</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 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>
|
||||
</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)"/>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user