初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
<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 - 140px);
|
||||
}
|
||||
.pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 12px 8px;
|
||||
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;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.title-input { font-size: 14px; font-weight: 500; 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>
|
||||
Reference in New Issue
Block a user