fix(biji): 优化笔记编辑器自动保存机制并修复相关问题
- 实现了更可靠的自动保存功能,仅在用户停止输入后触发保存操作 - 修复了切换笔记时意外触发自动保存的问题 - 优化了重命名文件后的预览更新逻辑 - 调整了保存成功后的状态清理策略,提高了用户体验
This commit is contained in:
@@ -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 + " 个冗余图片");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ spring:
|
|||||||
# 禁用Knife4j
|
# 禁用Knife4j
|
||||||
knife4j:
|
knife4j:
|
||||||
enable: false
|
enable: false
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-request-size: 10MB
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +338,17 @@ 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) {
|
||||||
|
// 直接更新当前选中文件的标题
|
||||||
|
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
|
previewFile(selectedFile.value); // Refresh preview
|
||||||
} else {
|
} else {
|
||||||
resetToHomeView();
|
resetToHomeView();
|
||||||
|
|||||||
@@ -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('获取回收站内容失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(``);
|
||||||
|
} 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>
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`:
|
|
||||||
|
|
||||||
@@ -4,3 +4,19 @@ This file tracks the overall progress of the project. Major milestones and compl
|
|||||||
|
|
||||||
## 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证结果**
|
||||||
|
✅ 修复后保存操作正常返回预览页,保持编辑状态
|
||||||
BIN
mydatabase.db
BIN
mydatabase.db
Binary file not shown.
Reference in New Issue
Block a user