初始提交:实习工作日志系统(设计方案 + Vue3 应用 + Electron 打包)

This commit is contained in:
root
2026-10-15 23:55:27 +08:00
commit cfb984627d
50 changed files with 9223 additions and 0 deletions
+255
View File
@@ -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>