feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+18 -6
View File
@@ -3,6 +3,8 @@ 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 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 }>()
@@ -18,6 +20,7 @@ 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 searchInput = ref<HTMLInputElement | null>(null)
const mobileDetail = ref(window.innerWidth <= 930)
let detailOpener: HTMLElement | null = null
@@ -87,13 +90,20 @@ function closeDetail() {
detailOpener = null
void nextTick(() => (opener?.isConnected ? opener : searchInput.value)?.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 && !window.confirm('有未保存的更改,确定切换吗?')) 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 && !window.confirm('有未保存的更改,确定切换吗?')) return
if (editor.value?.dirty && !(await confirmDiscard('切换后,当前草稿不会保存。'))) return
if (opener instanceof HTMLElement) detailOpener = opener
const token = ++detailGeneration
try {
@@ -105,7 +115,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
}
async function createMemo() {
if (scope.value === 'trash') return
if (editor.value?.dirty && !window.confirm('有未保存的更改,确定新建吗?')) 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 }
@@ -195,13 +205,15 @@ defineExpose({ createMemo, requestClose: () => editor.value?.requestClose(), dir
</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>{{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="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" :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)"/>
<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>