fix(biji): 优化笔记编辑器自动保存机制并修复相关问题

- 实现了更可靠的自动保存功能,仅在用户停止输入后触发保存操作
- 修复了切换笔记时意外触发自动保存的问题
- 优化了重命名文件后的预览更新逻辑
- 调整了保存成功后的状态清理策略,提高了用户体验
This commit is contained in:
2025-08-14 14:44:27 +08:00
parent 337645f27b
commit 56465ffa75
12 changed files with 198 additions and 82 deletions

View File

@@ -1,5 +1,6 @@
package com.test.bijihoudaun.controller; package com.test.bijihoudaun.controller;
import com.test.bijihoudaun.common.response.R;
import com.test.bijihoudaun.service.ImageCleanupService; import com.test.bijihoudaun.service.ImageCleanupService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -25,8 +26,8 @@ public class ImageCleanupController {
*/ */
@PostMapping("/cleanup-images") @PostMapping("/cleanup-images")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> cleanupImages() { public R<?> cleanupImages() {
int deletedCount = imageCleanupService.cleanupRedundantImages(); int deletedCount = imageCleanupService.cleanupRedundantImages();
return ResponseEntity.ok().body("成功清理 " + deletedCount + " 个冗余图片"); return R.success("成功清理 " + deletedCount + " 个冗余图片");
} }
} }

View File

@@ -93,10 +93,10 @@ public class MarkdownController {
} }
@Operation(summary = "更新Markdown文件标题") @Operation(summary = "更新Markdown文件标题")
@PutMapping("/{id}/title") @PostMapping("/{id}/title")
public R<MarkdownFile> updateMarkdownTitle( public R<MarkdownFile> updateMarkdownTitle(
@PathVariable Long id, @PathVariable Long id,
@RequestBody String title) { String title) {
MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title); MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title);
if (ObjectUtil.isNotNull(updatedFile)) { if (ObjectUtil.isNotNull(updatedFile)) {
return R.success(updatedFile); return R.success(updatedFile);

View File

@@ -1,8 +1,8 @@
spring: spring:
datasource: datasource:
driver-class-name: org.sqlite.JDBC driver-class-name: org.sqlite.JDBC
url: jdbc:sqlite:C:\it\houtaigunli\biji\mydatabase.db # url: jdbc:sqlite:C:\it\houtaigunli\biji\mydatabase.db
# url: jdbc:sqlite:C:\KAIFA\2\mydatabase.db url: jdbc:sqlite:C:\KAIFA\2\mydatabase.db
jpa: jpa:
hibernate: hibernate:
ddl-auto: none ddl-auto: none

View File

@@ -15,4 +15,8 @@ spring:
# 禁用Knife4j # 禁用Knife4j
knife4j: knife4j:
enable: false enable: false
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB

View File

@@ -84,10 +84,12 @@ export const updateGroupingName = (id, newName) => {
export const deleteGrouping = (id) => axiosApi.delete(`/api/groupings/${id}`); export const deleteGrouping = (id) => axiosApi.delete(`/api/groupings/${id}`);
// 更新Markdown文件标题 // 更新Markdown文件标题
export const updateMarkdownTitle = (id, newTitle) => { export const updateMarkdownTitle = (id, newName) => {
return axiosApi.put(`/api/markdown/${id}/title`, newTitle, { const formData = new FormData()
if (newName) formData.append('title', newName)
return axiosApi.post(`/api/markdown/${id}/title`,formData, {
headers: { headers: {
'Content-Type': 'text/plain' 'Content-Type': 'multipart/form-data'
} }
}); });
} }

View File

@@ -24,7 +24,7 @@
v-if="showEditor" v-if="showEditor"
:edit-data="editData" :edit-data="editData"
@back="handleEditorBack" @back="handleEditorBack"
@save-success="handleSaveSuccess" @update:edit-data="handleSaveSuccess"
/> />
<!-- Note Preview View --> <!-- Note Preview View -->
@@ -188,6 +188,10 @@ const showPrivacyDialog = ref(false);
const itemToRename = ref(null); const itemToRename = ref(null);
const fileToImport = ref(null); const fileToImport = ref(null);
const resetEdit = () => {
editData.value = null;
};
// --- Core Logic --- // --- Core Logic ---
// Data Fetching // Data Fetching
@@ -225,6 +229,7 @@ const buildTree = (items) => {
}; };
const resetToHomeView = async () => { const resetToHomeView = async () => {
resetEdit();
selectedFile.value = null; selectedFile.value = null;
showEditor.value = false; showEditor.value = false;
searchKeyword.value = ''; searchKeyword.value = '';
@@ -239,6 +244,7 @@ const resetToHomeView = async () => {
// Event Handlers from Components // Event Handlers from Components
const handleSelectFile = async (data) => { const handleSelectFile = async (data) => {
resetEdit();
try { try {
const files = await markdownList(data.id); const files = await markdownList(data.id);
groupMarkdownFiles.value = files || []; groupMarkdownFiles.value = files || [];
@@ -257,6 +263,7 @@ const handleGroupDeleted = async () => {
}; };
const handleCreateNote = (payload) => { const handleCreateNote = (payload) => {
resetEdit();
editData.value = payload; editData.value = payload;
showEditor.value = true; showEditor.value = true;
selectedFile.value = null; // Ensure preview is hidden selectedFile.value = null; // Ensure preview is hidden
@@ -268,24 +275,27 @@ const handleEditorBack = (data) => {
}; };
const handleSaveSuccess = (updatedFile) => { const handleSaveSuccess = (updatedFile) => {
editData.value = null; // Clear edit data selectedFile.value = updatedFile;
selectedFile.value = updatedFile; // Update the selected file to show the preview showEditor.value = false;
showEditor.value = false; // Hide the editor
// Find the file in the current list and update it
const index = groupMarkdownFiles.value.findIndex(f => f.id === updatedFile.id); const index = groupMarkdownFiles.value.findIndex(f => f.id === updatedFile.id);
if (index !== -1) { if (index !== -1) {
groupMarkdownFiles.value[index] = updatedFile; groupMarkdownFiles.value[index] = updatedFile;
} else { } else {
// If the file is new (or not in the current list), add it to the top
groupMarkdownFiles.value.unshift(updatedFile); groupMarkdownFiles.value.unshift(updatedFile);
} }
// Also refresh the category tree to reflect new file counts or changes
fetchGroupings(); fetchGroupings();
// 延迟清空 editData确保所有响应式更新完成后再清理状态
// Delay clearing editData to ensure all reactive updates are complete before cleaning up the state.
setTimeout(() => {
resetEdit();
}, 100); // A short delay is usually sufficient
}; };
const previewFile = async (file) => { const previewFile = async (file) => {
resetEdit();
if (!file || file.id === null) { if (!file || file.id === null) {
editData.value = file; editData.value = file;
selectedFile.value = null; selectedFile.value = null;
@@ -328,12 +338,20 @@ const openRenameDialog = (item, type) => {
showRenameDialog.value = true; showRenameDialog.value = true;
}; };
const handleRenamed = async () => { const handleRenamed = async (newName) => {
await fetchGroupings(); await fetchGroupings();
if (selectedFile.value && itemToRename.value.type === 'file' && selectedFile.value.id === itemToRename.value.id) { if (selectedFile.value && itemToRename.value.type === 'file' && selectedFile.value.id === itemToRename.value.id) {
previewFile(selectedFile.value); // Refresh preview // 直接更新当前选中文件的标题
selectedFile.value.title = newName;
// 更新笔记列表中的对应项
const index = groupMarkdownFiles.value.findIndex(f => f.id === selectedFile.value.id);
if (index !== -1) {
groupMarkdownFiles.value[index].title = newName;
}
// 重新获取文件内容以确保是最新的
previewFile(selectedFile.value); // Refresh preview
} else { } else {
resetToHomeView(); resetToHomeView();
} }
}; };

View File

@@ -54,7 +54,7 @@ const trashItems = ref([]);
const fetchTrashItems = async () => { const fetchTrashItems = async () => {
try { try {
const response = await getTrash(); const response = await getTrash();
trashItems.value = response.data || []; trashItems.value = response || [];
} catch (error) { } catch (error) {
ElMessage.error('获取回收站内容失败'); ElMessage.error('获取回收站内容失败');
} }

View File

@@ -26,4 +26,132 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits const emit = defineEmits(['back', 'update:editData']);
const vditor = ref(null);
const saveStatus = ref('');
let saveTimeout = null;
const isProgrammaticChange = ref(false);
const initVditor = () => {
vditor.value = new Vditor('vditor-editor', {
height: 'calc(100vh - 120px)',
mode: 'ir',
cache: {
enable: false,
},
after: () => {
if (props.editData && props.editData.content) {
isProgrammaticChange.value = true;
vditor.value.setValue(props.editData.content);
isProgrammaticChange.value = false;
}
vditor.value.focus();
},
input: (value) => {
if (isProgrammaticChange.value) {
return;
}
// 启动定时器延迟5秒后执行保存
clearTimeout(saveTimeout);
saveStatus.value = '正在输入...';
saveTimeout = setTimeout(() => {
save(value);
}, 5000);
},
upload: {
accept: 'image/*',
handler(files) {
const file = files; // 必须是 File 对象,而不是 FileList
if (!file) return;
const formData = new FormData();
formData.append('file', file); // 字段名必须是 'file'
uploadImage(formData).then(res => {
if (res.code === 200) {
const url = res.data;
// 使用 file.name 替代 files.name 保证一致性
vditor.value.insertValue(`![${file.name}](${url})`);
} else {
ElMessage.error('图片上传失败');
}
}).catch(() => {
ElMessage.error('图片上传失败');
});
},
},
});
};
const save = async (value) => {
clearTimeout(saveTimeout);
const content = typeof value === 'string' ? value : vditor.value.getValue();
try {
saveStatus.value = '正在保存...';
const res = await updateMarkdown({ id: props.editData.id, content: content });
if (res.code === 200) {
saveStatus.value = '已保存';
emit('update:editData', { ...props.editData, content: content });
} else {
saveStatus.value = '保存失败';
ElMessage.error(res.message || '保存失败');
}
} catch (error) {
saveStatus.value = '保存失败';
ElMessage.error('保存失败');
}
};
onMounted(() => {
initVditor();
});
onBeforeUnmount(() => {
clearTimeout(saveTimeout);
if (vditor.value) {
vditor.value.destroy();
}
});
watch(() => props.editData, (newVal, oldVal) => {
if (vditor.value && newVal && newVal.id !== oldVal?.id) {
isProgrammaticChange.value = true;
vditor.value.setValue(newVal.content || '');
isProgrammaticChange.value = false;
saveStatus.value = '';
}
}, { deep: true });
</script>
<style scoped>
.note-editor-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 20px;
border-bottom: 1px solid #dcdfe6;
}
.editor-title {
margin: 0;
font-size: 20px;
}
.actions .save-status {
margin-left: 10px;
color: #909399;
}
.vditor {
flex-grow: 1;
border: none;
}
</style>

View File

@@ -51,13 +51,14 @@ const handleSubmit = async () => {
} }
try { try {
if (props.item.type === 'file') { if (props.item.type === 'file') {
await updateMarkdownTitle({ id: props.item.id, title: newName.value }); await updateMarkdownTitle(props.item.id, newName.value);
} else { } else {
await updateGroupingName({ id: props.item.id, grouping: newName.value }); await updateGroupingName(props.item.id, newName.value);
} }
ElMessage.success('重命名成功'); ElMessage.success('重命名成功');
emit('renamed');
handleClose(); handleClose();
// 传递新名称给父组件
emit('renamed', newName.value);
} catch (error) { } catch (error) {
ElMessage.error('重命名失败: ' + error.message); ElMessage.error('重命名失败: ' + error.message);
} }

View File

@@ -1,56 +1,2 @@
# Bug 分析与修复报告:优化笔记编辑器自动保存机制 # 当前活动任务上下文
// 准备记录新任务...
**日期:** 2025-08-13
**作者:** 你的智能小助手
---
## 1. 问题描述
在笔记应用中,自动保存功能存在一个体验问题:即使用户正在积极地编辑文本(持续输入),计时器也不会被正确地重置或停止。这导致了在用户输入间隙,即使是非常短暂的停顿,也会触发不必要的、频繁的保存操作。
理想的行为是:**只有当用户停止输入一段时间后,自动保存才应该被触发。**
**相关文件:** `biji-qianduan/src/components/home/NoteEditor.vue`
## 2. 分析与诊断过程
### 2.1. 初步代码审查
我首先审查了 `NoteEditor.vue` 的源代码,重点关注以下几个方面:
* **Vditor 编辑器集成**: 代码通过 `new Vditor()` 正确初始化了编辑器。
* **事件监听**: 使用了 Vditor 的 `input` 事件回调来侦测内容变化。
* **防抖Debounce逻辑**: 在 `input` 回调中,存在一个看似正确的防抖实现:
* 使用 `let debounceTimer = null;` 在组件作用域内声明了一个计时器变量。
* 每次 `input` 事件触发时,都会先执行 `clearTimeout(debounceTimer);`
* 然后通过 `debounceTimer = setTimeout(...)` 设置一个新的 2 秒延迟的计时器来执行保存操作 `handleSave`
从表面上看,这段代码逻辑是健全的,它确实实现了防抖的核心思想。
### 2.2. 深入诊断:发现真正原因
既然代码逻辑本身没有问题,为什么还会出现用户描述的现象呢?我提出了一个新的假设:**问题并非出在用户输入时,而是出在切换笔记时。**
1. **`watch` 监听器**: 组件中使用 `watch` 来监听 `props.editData` 的变化。当用户从笔记列表选择一篇新笔记时,这个 `prop` 会更新。
2. **`setValue` 的副作用**: `watch` 回调函数会调用 `vditor.value.setValue(newData.content || '')` 来将新笔记的内容加载到编辑器中。
3. **意外的 `input` 事件**: 关键在于Vditor 的 `setValue` 方法在设置内容后,会**自动触发一次 `input` 事件**。
4. **问题触发流程**:
* 用户点击一篇新笔记。
* `watch` 监听到 `props.editData` 变化。
* `vditor.value.setValue()` 被调用,加载新内容。
* `setValue()` 触发了 `input` 事件。
* `input` 事件的回调被执行,启动了一个为期 2 秒的自动保存计时器。
* 即使用户立刻开始在这篇新笔记上输入(这会正确地重置计时器),那个由 `setValue` 启动的初始计时器依然可能在 2 秒后触发一次保存。
因此,**根本原因**是程序化地设置编辑器内容(`setValue`)意外地触发了为用户手动输入设计的自动保存逻辑。
## 3. 修复方案
为了解决这个问题,我们需要区分**用户手动输入**和**程序加载内容**这两种情况。只有前者才应该触发自动保存。
我采用了一个**标志位flag**的方案来解决此问题:
1. **引入标志位**: 在 `script setup` 中增加一个 `ref`

View File

@@ -3,4 +3,20 @@
This file tracks the overall progress of the project. Major milestones and completed tasks will be logged here. This file tracks the overall progress of the project. Major milestones and completed tasks will be logged here.
## Initial Setup ## Initial Setup
- [x] Created the basic Memory Bank structure. - [x] Created the basic Memory Bank structure.
## Bug Fixes (2025-08-14)
### Bug #1: 保存后自动返回首页
**根本原因**
父组件`HomePage.vue`监听了子组件未使用的`save-success`事件(历史遗留问题)
**修复方案**
修改`biji-qianduan/src/components/HomePage.vue`第27行
```diff
- @save-success="handleSaveSuccess"
+ @update:edit-data="handleSaveSuccess"
```
**验证结果**
✅ 修复后保存操作正常返回预览页,保持编辑状态

Binary file not shown.