Files
daywork/intern-log/src/views/MemoView.vue
T

256 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>