206 lines
11 KiB
Vue
206 lines
11 KiB
Vue
<script setup lang="ts">
|
|
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'
|
|
import AppSheet from './components/AppSheet.vue'
|
|
|
|
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 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<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 mobileLayout = ref(window.innerWidth <= 930)
|
|
const mobileDetail = ref(mobileLayout.value)
|
|
let detailOpener: HTMLElement | null = null
|
|
let generation = 0
|
|
let committedGeneration = 0
|
|
let detailGeneration = 0
|
|
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 } }
|
|
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(() => scope.value === 'trash' ? '回收站是空的' : '还没有备忘录')
|
|
|
|
async function load(reset = true) {
|
|
const token = ++generation
|
|
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: 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]
|
|
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, 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) 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.focus())
|
|
}
|
|
function showConfirm(options: AppDialogOptions) {
|
|
return appDialog.value?.show(options).then((result) => result === true) ?? Promise.resolve(false)
|
|
}
|
|
function confirmDiscard(description: string) {
|
|
if (!editor.value?.dirty) return Promise.resolve(true)
|
|
return showConfirm({ title: '放弃未保存的更改?', description, danger: true, confirmText: '放弃更改' })
|
|
}
|
|
async function setScope(next: 'active' | 'trash') {
|
|
if (next === scope.value) return
|
|
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
|
closeDetail(); scope.value = next; emit('scope', next); await load()
|
|
}
|
|
async function selectMemo(id: string, opener?: EventTarget | null) {
|
|
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
|
|
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 && !(await confirmDiscard('新建后,当前草稿不会保存。'))) return
|
|
const token = ++detailGeneration
|
|
selectedToken.value = token
|
|
selected.value = { id: null, title: '', content: '', version: null, created_at: null, updated_at: null, deleted_at: null }
|
|
detailOpener = null
|
|
emit('detail', true)
|
|
}
|
|
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)
|
|
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 (!matchesCriteria(memo, saveContext.criteria)) {
|
|
if (index >= 0) {
|
|
items.value.splice(index, 1)
|
|
total.value = Math.max(0, total.value - 1)
|
|
}
|
|
return
|
|
}
|
|
const listItem = memo
|
|
if (index < 0) {
|
|
items.value.push(listItem)
|
|
total.value += 1
|
|
} else items.value[index] = listItem
|
|
items.value.sort(compareMemos)
|
|
}
|
|
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.focus())
|
|
}
|
|
function removeRestoredItem(memo: MemoRecord, selectionToken: number) { removeItem(memo.id, selectionToken) }
|
|
function updateLayout() {
|
|
mobileLayout.value = window.innerWidth <= 930
|
|
mobileDetail.value = mobileLayout.value
|
|
}
|
|
onMounted(() => { window.addEventListener('resize', updateLayout); void load() })
|
|
onUnmounted(() => { detailGeneration += 1; generation += 1; window.removeEventListener('resize', updateLayout) })
|
|
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>
|
|
</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>{{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="items.length < total" class="secondary memo-load-more" :disabled="loading || refreshing || !criteriaMatch" @click="loadMore">{{loading || refreshing?'正在加载…':'加载更多'}}</button>
|
|
</div>
|
|
<AppSheet :open="Boolean(selected)" :modal="mobileDetail" variant="detail" panel-class="memo-editor" title-id="memo-editor-title" initial-focus="input[aria-label='备忘录标题']" :close-on-scrim="mobileDetail" @close="editor?.requestClose()">
|
|
<MemoEditor v-if="selected" ref="editor" :memo="selected" :request="request" :mobile="mobileDetail" :selection-token="selectedToken" :confirm-action="showConfirm" @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)"/>
|
|
</AppSheet>
|
|
<AppDialog ref="appDialog" />
|
|
</section>
|
|
</template>
|