256 lines
7.7 KiB
Vue
256 lines
7.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||
import { addMemo, deleteMemo, listMemos, updateMemo, type Memo } from '../db/repo'
|
||
import { showToast } from '../lib/toast'
|
||
import { focusMemo } from '../lib/bus'
|
||
|
||
const memos = ref<Memo[]>([])
|
||
const current = ref<Memo | null>(null)
|
||
const editing = ref(false)
|
||
const draftTitle = ref('')
|
||
const draftContent = ref('')
|
||
const deleteTarget = ref<Memo | null>(null)
|
||
const pendingAction = ref<null | { type: 'select'; memo: Memo } | { type: 'new' }>(null)
|
||
|
||
async function load() {
|
||
memos.value = await listMemos()
|
||
if (current.value && current.value.id) {
|
||
const fresh = memos.value.find(m => m.id === current.value!.id)
|
||
if (fresh) current.value = fresh
|
||
}
|
||
}
|
||
onMounted(async () => {
|
||
await load()
|
||
if (memos.value.length) applySelect(memos.value[0])
|
||
window.addEventListener('keydown', onGlobalKey)
|
||
})
|
||
onUnmounted(() => window.removeEventListener('keydown', onGlobalKey))
|
||
|
||
// 全局搜索命中备忘录后跳转并选中
|
||
watch(focusMemo, async id => {
|
||
if (!id) return
|
||
if (!memos.value.length) await load()
|
||
const m = memos.value.find(x => x.id === id)
|
||
focusMemo.value = null
|
||
if (m) selectMemo(m)
|
||
}, { immediate: true })
|
||
|
||
// Ctrl+S / Cmd+S 保存当前备忘
|
||
function onGlobalKey(e: KeyboardEvent) {
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||
if (editing.value && dirty.value) {
|
||
e.preventDefault()
|
||
save()
|
||
}
|
||
}
|
||
}
|
||
|
||
const dirty = computed(() => {
|
||
if (!editing.value) return false
|
||
const baseTitle = current.value ? current.value.title : ''
|
||
const baseContent = current.value ? current.value.content : ''
|
||
return draftTitle.value !== baseTitle || draftContent.value !== baseContent
|
||
})
|
||
|
||
function applySelect(m: Memo) {
|
||
current.value = m
|
||
editing.value = true
|
||
draftTitle.value = m.title
|
||
draftContent.value = m.content
|
||
}
|
||
function selectMemo(m: Memo) {
|
||
if (editing.value && current.value && current.value.id === m.id) return
|
||
if (dirty.value) {
|
||
pendingAction.value = { type: 'select', memo: m }
|
||
return
|
||
}
|
||
applySelect(m)
|
||
}
|
||
function newMemo() {
|
||
if (dirty.value) {
|
||
pendingAction.value = { type: 'new' }
|
||
return
|
||
}
|
||
startNew()
|
||
}
|
||
function startNew() {
|
||
current.value = null
|
||
editing.value = true
|
||
draftTitle.value = ''
|
||
draftContent.value = ''
|
||
}
|
||
function resetDraft() {
|
||
if (current.value) applySelect(current.value)
|
||
else startNew()
|
||
}
|
||
async function save() {
|
||
if (!draftTitle.value.trim() && !draftContent.value.trim()) {
|
||
showToast('标题与内容不能都为空')
|
||
return
|
||
}
|
||
if (current.value && current.value.id) {
|
||
await updateMemo(current.value.id, draftTitle.value, draftContent.value)
|
||
showToast('备忘已保存')
|
||
} else {
|
||
const m = await addMemo(draftTitle.value, draftContent.value)
|
||
current.value = m
|
||
draftTitle.value = m.title
|
||
draftContent.value = m.content
|
||
showToast('备忘已创建')
|
||
}
|
||
await load()
|
||
}
|
||
function askDelete() {
|
||
if (current.value && current.value.id) deleteTarget.value = current.value
|
||
}
|
||
async function confirmDelete() {
|
||
if (deleteTarget.value && deleteTarget.value.id) {
|
||
await deleteMemo(deleteTarget.value.id)
|
||
showToast('备忘已删除')
|
||
}
|
||
deleteTarget.value = null
|
||
await load()
|
||
startNew()
|
||
}
|
||
function confirmProceed() {
|
||
const a = pendingAction.value
|
||
pendingAction.value = null
|
||
if (!a) return
|
||
if (a.type === 'select') applySelect(a.memo)
|
||
else startNew()
|
||
}
|
||
function snippet(m: Memo): string {
|
||
return (m.content || '').replace(/\s+/g, ' ').slice(0, 30)
|
||
}
|
||
function fmtTime(s: string): string {
|
||
return s.replace('T', ' ').slice(0, 16)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="memo-page">
|
||
<aside class="card memo-list">
|
||
<div class="pane-head">
|
||
<span class="pane-title">备忘录({{ memos.length }})</span>
|
||
<button class="btn btn-primary btn-sm" @click="newMemo">新建</button>
|
||
</div>
|
||
<div class="memo-scroll">
|
||
<div
|
||
v-for="m in memos" :key="m.id"
|
||
class="memo-item" :class="{ active: editing && current && current.id === m.id }"
|
||
@click="selectMemo(m)"
|
||
>
|
||
<div class="memo-item-title">{{ m.title || '无标题' }}</div>
|
||
<div class="muted memo-item-sub">{{ snippet(m) || '空' }}</div>
|
||
<div class="muted num memo-item-time">{{ fmtTime(m.updated_at) }}</div>
|
||
</div>
|
||
<div v-if="!memos.length" class="muted empty">暂无备忘</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<section class="card memo-editor">
|
||
<template v-if="editing">
|
||
<input class="input title-input" v-model="draftTitle" placeholder="标题" />
|
||
<textarea class="textarea content-textarea" v-model="draftContent" placeholder="内容"></textarea>
|
||
<div class="editor-foot">
|
||
<span class="muted foot-info">
|
||
<template v-if="dirty">有未保存修改</template>
|
||
<template v-else-if="current && current.id">更新于 {{ fmtTime(current.updated_at) }}</template>
|
||
<template v-else>新备忘</template>
|
||
</span>
|
||
<span class="flex1"></span>
|
||
<button v-if="current && current.id" class="btn btn-sm" @click="askDelete">删除</button>
|
||
<button class="btn btn-sm" :disabled="!dirty" @click="resetDraft">放弃修改</button>
|
||
<button class="btn btn-primary btn-sm" :disabled="!dirty" @click="save">保存</button>
|
||
</div>
|
||
</template>
|
||
<div v-else class="empty muted editor-empty">选择左侧备忘,或点击右上角新建</div>
|
||
</section>
|
||
|
||
<ConfirmDialog
|
||
:open="!!deleteTarget"
|
||
title="删除确认"
|
||
:message="deleteTarget ? '将删除备忘「' + (deleteTarget.title || '无标题') + '」。删除后不可恢复。' : ''"
|
||
confirm-text="删除" danger
|
||
@cancel="deleteTarget = null"
|
||
@confirm="confirmDelete"
|
||
/>
|
||
<ConfirmDialog
|
||
:open="!!pendingAction"
|
||
title="未保存提示"
|
||
message="当前备忘有未保存的修改,继续操作将丢失这些修改。"
|
||
confirm-text="放弃修改并继续"
|
||
@cancel="pendingAction = null"
|
||
@confirm="confirmProceed"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.memo-page {
|
||
display: grid;
|
||
grid-template-columns: 280px minmax(0, 1fr);
|
||
gap: 12px;
|
||
align-items: stretch;
|
||
}
|
||
.memo-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: calc(100vh - 150px);
|
||
}
|
||
.pane-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 12px 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
flex: none;
|
||
}
|
||
.pane-title { font-size: 13px; font-weight: 600; }
|
||
.memo-scroll { overflow: auto; padding: 6px; }
|
||
.memo-item {
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
margin-bottom: 2px;
|
||
}
|
||
.memo-item:hover { background: var(--bg-hover); }
|
||
.memo-item.active { background: var(--accent-bg); }
|
||
.memo-item.active .memo-item-title { color: var(--accent); }
|
||
.memo-item-title { font-size: 13px; font-weight: 500; word-break: break-all; }
|
||
.memo-item-sub { font-size: 12px; margin-top: 2px; word-break: break-all; }
|
||
.memo-item-time { font-size: 11px; margin-top: 2px; }
|
||
|
||
.memo-editor {
|
||
padding: 16px 18px;
|
||
min-height: 420px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.title-input { font-size: 15px; font-weight: 600; margin-bottom: 10px; }
|
||
.content-textarea { flex: 1; min-height: 340px; line-height: 1.8; }
|
||
.editor-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.foot-info { font-size: 12px; }
|
||
.editor-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 1;
|
||
padding: 60px 0;
|
||
}
|
||
|
||
@media (max-width: 800px) {
|
||
.memo-page { grid-template-columns: 1fr; }
|
||
.memo-list { max-height: 240px; }
|
||
.content-textarea { min-height: 240px; }
|
||
}
|
||
</style>
|