Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Update notes column nullability

Revision ID: db38fc2274be
Revises: 3cc16d4f8bbb
Create Date: 2026-02-01 13:46:10.705890

"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "db38fc2274be"
down_revision: Union[str, Sequence[str], None] = "3cc16d4f8bbb"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# 将 notes 表的 title 字段设置为必填(NOT NULL)
op.alter_column("notes", "title", existing_type=sa.VARCHAR(), nullable=False)
# 将 notes 表的 content 字段设置为可空(允许为空)
op.alter_column("notes", "content", existing_type=sa.TEXT(), nullable=True)


def downgrade() -> None:
"""Downgrade schema."""
# 回滚:将 content 字段改回必填
op.alter_column("notes", "content", existing_type=sa.TEXT(), nullable=False)
# 回滚:将 title 字段改回可空
op.alter_column("notes", "title", existing_type=sa.VARCHAR(), nullable=True)
4 changes: 2 additions & 2 deletions api/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ class Note(Base):
)
user_id = Column(String(36), ForeignKey("users.id")) # 创建者
workspace_id = Column(String(36), ForeignKey("workspaces.id"), nullable=False)
title: Mapped[Optional[str]] = mapped_column(String, nullable=True, index=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
title: Mapped[Optional[str]] = mapped_column(String, nullable=False, index=True)
content: Mapped[str] = mapped_column(Text, nullable=True)
# visibility: PRIVATE / PROTECTED / PUBLIC, default PRIVATE
visibility = Column(String, default="PRIVATE", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/FileGrid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getFileIcon, getFileTypeColor, isImage } from '@/utils/file'
const props = defineProps({
files: {
type: Array,
required: true,
required: false,
default: () => [],
},
folders: {
Expand Down
129 changes: 110 additions & 19 deletions web/src/components/NoteEditor.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, computed, onBeforeUnmount } from 'vue'
import noteService from '../api/noteService'
import FileFolderSelector from './FileFolderSelector.vue'
import { useToast } from '@/composables/useToast'
Expand All @@ -13,41 +13,123 @@ const props = defineProps({
},
})

const emit = defineEmits(['save', 'cancel'])
const emit = defineEmits(['save', 'cancel', 'auto-save', 'update:isDirty'])

const title = ref('')
const content = ref('')
const attachedFiles = ref([])
const attachedFolders = ref([])
const showSelector = ref(false)

const lastSavedState = ref({ title: '', content: '' })
const saveStatus = ref('')
let autoSaveTimer = null
const currentNoteId = ref(null)

const { showToast } = useToast()

const isDirty = computed(() => {
return (
title.value !== lastSavedState.value.title || content.value !== lastSavedState.value.content
)
})

watch(isDirty, (newVal) => {
emit('update:isDirty', newVal)
})

// 监听属性变化
watch(
() => props.note,
(newNote) => {
(newNote, oldNote) => {
// 如果ID改变了,或者之前没有ID(新建),则视为切换了笔记,需要重置状态
// 如果是同一个ID,可能是自动保存后的更新,尽量不打断用户输入
const isNewNote = !oldNote || newNote?.id !== oldNote.id

if (newNote) {
title.value = newNote.title || ''
content.value = newNote.content || ''
attachedFiles.value = newNote.files || []
attachedFolders.value = newNote.folders || []
if (isNewNote) {
title.value = newNote.title || ''
content.value = newNote.content || ''
attachedFiles.value = newNote.files || []
attachedFolders.value = newNote.folders || []
currentNoteId.value = newNote.id

// 更新最后保存状态
lastSavedState.value = {
title: newNote.title || '',
content: newNote.content || '',
}
} else {
// 同一个笔记更新,只更新非编辑字段
attachedFiles.value = newNote.files || []
attachedFolders.value = newNote.folders || []
}
} else {
saveStatus.value = ''
title.value = ''
content.value = ''
attachedFiles.value = []
attachedFolders.value = []
currentNoteId.value = null
lastSavedState.value = { title: '', content: '' }
}
},
{ immediate: true },
{ immediate: true, deep: true },
)

// 自动保存逻辑
const triggerAutoSave = () => {
if (!title.value.trim() && !content.value.trim()) return

saveStatus.value = '正在保存...'
emit(
'auto-save',
{
title: title.value,
content: content.value,
},
(success) => {
if (success) {
saveStatus.value = '已自动保存'
lastSavedState.value = {
title: title.value,
content: content.value,
}
} else {
saveStatus.value = '自动保存失败'
}
},
)
}

// 监听内容变化触发自动保存
watch([title, content], () => {
if (isDirty.value) {
saveStatus.value = '有未保存内容'
clearTimeout(autoSaveTimer)
autoSaveTimer = setTimeout(triggerAutoSave, 2000)
}
})

onBeforeUnmount(() => {
clearTimeout(autoSaveTimer)
})

// 事件处理
const handleSubmit = () => {
if (!title.value.trim() || !content.value.trim()) {
const handleManualSave = () => {
if (!title.value.trim() && !content.value.trim()) {
return
}

// 手动保存时,清除自动保存定时器,避免重复提交
clearTimeout(autoSaveTimer)

// 更新最后保存状态,防止isDirty误判
lastSavedState.value = {
title: title.value,
content: content.value,
}

emit('save', {
title: title.value,
content: content.value,
Expand Down Expand Up @@ -123,15 +205,15 @@ const handleDetachFolder = async (folderId) => {

<template>
<div
class="bg-white dark:bg-gray-900 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 h-full flex flex-col"
class="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden shadow-xl border border-gray-200 dark:border-gray-700 h-full flex flex-col"
>
<!-- 头部工具栏 -->
<div
class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
<div class="w-11 h-11 bg-blue-500 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
Expand All @@ -141,18 +223,27 @@ const handleDetachFolder = async (folderId) => {
></path>
</svg>
</div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ note ? '编辑笔记' : '新建笔记' }}
</h2>
<div class="flex flex-col md:flex-row items-start md:items-center justify-center">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ note ? '编辑笔记' : '新建笔记' }}
</h2>

<div v-if="saveStatus" class="badge badge-info badge-xs md:mx-2">
{{ saveStatus }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button class="btn btn-sm btn-soft" @click="$emit('cancel')">✖️ 取消</button>
<button class="btn btn-sm btn-soft" @click="$emit('cancel')">
✖️ <span class="hidden sm:inline-block">取消</span>
</button>
<button
class="btn btn-sm btn-primary"
:disabled="!title.trim() || !content.trim()"
@click="handleSubmit"
:disabled="!title.trim() && !content.trim()"
@click="handleManualSave"
>
💾 保存
💾
<span class="hidden sm:inline-block">保存并预览</span>
</button>
</div>
</div>
Expand Down
38 changes: 37 additions & 1 deletion web/src/views/notes/NoteDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const loading = ref(false)
const isEditing = ref(false)
const detailsFile = ref(null)
const showDetails = ref(false)
const isNoteDirty = ref(false)

const noteId = computed(() => route.params.id)

Expand Down Expand Up @@ -68,6 +69,32 @@ const handleDelete = async () => {

const handleCancel = () => {
isEditing.value = false
isNoteDirty.value = false
}

const handleAutoSave = async (noteData, callback) => {
try {
const latest = await noteService.updateNote(noteId.value, noteData)

// 成功回调
if (callback) callback(true)

// 更新本地笔记数据(不重新加载整个笔记,避免打断编辑)
if (note.value) {
note.value = {
...note.value,
...latest,
...noteData,
}
}
} catch (error) {
console.error('Auto save failed', error)
if (callback) callback(false)
}
}

const handleDirtyUpdate = (val) => {
isNoteDirty.value = val
}

const handleBack = () => {
Expand Down Expand Up @@ -130,7 +157,16 @@ onMounted(async () => {

<!-- Editor Mode -->
<div v-else-if="isEditing && note" class="mx-auto flex-1 pb-2 overflow-auto">
<NoteEditor :note="note" @save="handleSave" @cancel="handleCancel" />
<!-- <NoteEditor :note="note" @save="handleSave" @cancel="handleCancel" /> -->

<NoteEditor
v-if="isEditing"
:note="note"
@save="handleSave"
@auto-save="handleAutoSave"
@cancel="handleCancel"
@update:is-dirty="handleDirtyUpdate"
/>
</div>

<!-- View Mode -->
Expand Down
Loading