feat(功能): 增加笔记重命名和导出功能- 在笔记列表和预览页面添加重命名功能

- 实现笔记内容自动保存机制
-增加笔记导出为 Markdown 文件的功能
- 优化后端接口,支持更新笔记标题
This commit is contained in:
2025-07-31 10:16:49 +08:00
parent 2f9e68c636
commit e0a99235ec
7 changed files with 230 additions and 34 deletions

View File

@@ -101,4 +101,16 @@ public class MarkdownController {
List<MarkdownFile> files = markdownFileService.searchByTitle(keyword); List<MarkdownFile> files = markdownFileService.searchByTitle(keyword);
return R.success(files); return R.success(files);
} }
@Operation(summary = "更新Markdown文件标题")
@PutMapping("/{id}/title")
public R<MarkdownFile> updateMarkdownTitle(
@PathVariable Long id,
@RequestBody String title) {
MarkdownFile updatedFile = markdownFileService.updateMarkdownTitle(id, title);
if (updatedFile != null) {
return R.success(updatedFile);
}
return R.fail("文件未找到或更新失败");
}
} }

View File

@@ -29,4 +29,10 @@ public interface GroupingService {
* @param id * @param id
*/ */
void deleteGrouping(Long id); void deleteGrouping(Long id);
/**
* 批量保存或更新
* @param groupings
*/
void saveOrUpdateBatch(List<Grouping> groupings);
} }

View File

@@ -58,4 +58,12 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
* @return 文件列表 * @return 文件列表
*/ */
List<MarkdownFile> searchByTitle(String keyword); List<MarkdownFile> searchByTitle(String keyword);
/**
* 更新Markdown文件标题
* @param id 文件ID
* @param title 新标题
* @return 更新后的文件对象
*/
MarkdownFile updateMarkdownTitle(Long id, String title);
} }

View File

@@ -11,7 +11,7 @@ import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
@Service @Service
public class GroupingServiceImpl public class GroupingServiceImpl
extends ServiceImpl<GroupingMapper, Grouping> extends ServiceImpl<GroupingMapper, Grouping>
implements GroupingService { implements GroupingService {
@@ -44,4 +44,9 @@ public class GroupingServiceImpl
public void deleteGrouping(Long id) { public void deleteGrouping(Long id) {
this.removeById(id); this.removeById(id);
} }
@Override
public void saveOrUpdateBatch(List<Grouping> groupings) {
super.saveOrUpdateBatch(groupings);
}
} }

View File

@@ -88,4 +88,15 @@ public class MarkdownFileServiceImpl
queryWrapper.like("title", keyword); queryWrapper.like("title", keyword);
return this.list(queryWrapper); return this.list(queryWrapper);
} }
@Override
public MarkdownFile updateMarkdownTitle(Long id, String title) {
MarkdownFile file = this.getById(id);
if (file != null) {
file.setTitle(title);
file.setUpdatedAt(new Date());
this.updateById(file);
}
return file;
}
} }

View File

@@ -74,9 +74,25 @@ export const register = (data) => {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data'
} }
}) })
} }
// 更新分组名称
export const updateGroupingName = (id, newName) => {
return axiosApi.put(`/api/groupings/${id}`, { grouping: newName });
}
// 更新Markdown文件标题
export const updateMarkdownTitle = (id, newTitle) => {
return axiosApi.put(`/api/markdown/${id}/title`, newTitle, {
headers: {
'Content-Type': 'text/plain'
}
});
}

View File

@@ -25,8 +25,11 @@
<!-- 分组分类 --> <!-- 分组分类 -->
<el-sub-menu v-for="group in groupings" :key="group.id" :index="`group-${group.id}`"> <el-sub-menu v-for="group in groupings" :key="group.id" :index="`group-${group.id}`">
<template #title> <template #title>
<el-icon><Folder /></el-icon> <div class="menu-item-title">
<span>{{ group.grouping }}</span> <el-icon><Folder /></el-icon>
<span>{{ group.grouping }}</span>
<el-icon class="edit-icon" @click.stop="openRenameDialog(group, 'group')"><Edit /></el-icon>
</div>
</template> </template>
<el-menu-item <el-menu-item
v-for="sub in jb22.filter(j => +j.parentId === +group.id)" v-for="sub in jb22.filter(j => +j.parentId === +group.id)"
@@ -46,13 +49,18 @@
<el-main class="content"> <el-main class="content">
<div v-if="selectedFile" class="file-preview"> <div v-if="selectedFile" class="file-preview">
<el-header class="preview-header"> <el-header class="preview-header">
<h2>{{ selectedFile.title }}</h2> <h2 class="preview-title">
{{ selectedFile.title }}
<el-icon class="edit-icon" @click="openRenameDialog(selectedFile, 'file')"><Edit /></el-icon>
</h2>
<div class="actions"> <div class="actions">
<el-button v-if="!showEditor" type="primary" @click="selectedFile = null">清空</el-button> <el-button v-if="!showEditor" type="primary" @click="selectedFile = null">清空</el-button>
<el-button v-if="!showEditor && userStore.isLoggedIn" type="primary" @click="editNote(selectedFile); isCollapsed = true">编辑</el-button> <el-button v-if="!showEditor && userStore.isLoggedIn" type="primary" @click="editNote(selectedFile); isCollapsed = true">编辑</el-button>
<el-button v-if="!showEditor && userStore.isLoggedIn" type="danger" @click="deleteNote(selectedFile)">删除</el-button> <el-button v-if="!showEditor && userStore.isLoggedIn" type="danger" @click="deleteNote(selectedFile)">删除</el-button>
<el-button v-if="showEditor" type="primary" @click="showEditor = !showEditor; previewFile(editData)">返回</el-button> <el-button v-if="showEditor" type="primary" @click="showEditor = !showEditor; previewFile(editData)">返回</el-button>
<el-button v-if="showEditor && userStore.isLoggedIn" type="success" @click="handleSave(vditor.getValue())">保存</el-button> <el-button v-if="showEditor && userStore.isLoggedIn" type="success" @click="handleSave(vditor.getValue())">保存</el-button>
<span v-if="showEditor" class="save-status">{{ saveStatus }}</span>
<el-button v-if="!showEditor" type="success" @click="handleExportMd">导出为.md</el-button>
</div> </div>
</el-header> </el-header>
<div v-if="!showEditor" v-html="previewHtml" class="markdown-preview"></div> <div v-if="!showEditor" v-html="previewHtml" class="markdown-preview"></div>
@@ -167,13 +175,38 @@
<el-button type="primary" @click="createNote">确定</el-button> <el-button type="primary" @click="createNote">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 重命名对话框 -->
<el-dialog v-model="showRenameDialog" title="重命名" width="400px">
<el-input v-model="newName" placeholder="请输入新名称"></el-input>
<template #footer>
<el-button @click="showRenameDialog = false">取消</el-button>
<el-button type="primary" @click="handleRename">确定</el-button>
</template>
</el-dialog>
<!-- 导入选择分类对话框 -->
<el-dialog v-model="showSelectGroupDialog" title="选择导入的分类" width="400px">
<el-select v-model="importGroupId" placeholder="请选择分类">
<el-option
v-for="group in jb22"
:key="group.id"
:label="group.grouping"
:value="group.id"
></el-option>
</el-select>
<template #footer>
<el-button @click="showSelectGroupDialog = false">取消</el-button>
<el-button type="primary" @click="confirmImport">确定</el-button>
</template>
</el-dialog>
</el-main> </el-main>
</el-container> </el-container>
</el-container> </el-container>
</template> </template>
<script setup> <script setup>
import {onMounted, ref, nextTick} from 'vue'; import {onMounted, ref, nextTick, watch} from 'vue';
import {ElMessage} from 'element-plus'; import {ElMessage} from 'element-plus';
import Vditor from 'vditor'; import Vditor from 'vditor';
import 'vditor/dist/index.css'; import 'vditor/dist/index.css';
@@ -185,9 +218,11 @@ import {
markdownAll, markdownList, markdownAll, markdownList,
Preview, Preview,
updateMarkdown, uploadImage, updateMarkdown, uploadImage,
searchMarkdown searchMarkdown,
updateGroupingName,
updateMarkdownTitle
} from '@/api/CommonApi.js' } from '@/api/CommonApi.js'
import { DArrowRight, Plus, Fold, Expand, Folder, Document, Search } from "@element-plus/icons-vue"; import { DArrowRight, Plus, Fold, Expand, Folder, Document, Search, Edit } from "@element-plus/icons-vue";
import { useUserStore } from '../stores/user'; import { useUserStore } from '../stores/user';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -210,6 +245,12 @@ const activeMenu = ref('all');
const isCollapsed = ref(false); const isCollapsed = ref(false);
const showCreateGroupDialog = ref(false); const showCreateGroupDialog = ref(false);
const showCreateNoteDialog = ref(false); const showCreateNoteDialog = ref(false);
const showRenameDialog = ref(false);
const itemToRename = ref(null);
const newName = ref('');
const showSelectGroupDialog = ref(false);
const importGroupId = ref(null);
const fileToImport = ref(null);
const groupFormRef = ref(null); const groupFormRef = ref(null);
const newGroupForm = ref({ name: '', parentId: null }); const newGroupForm = ref({ name: '', parentId: null });
@@ -247,6 +288,8 @@ const jb22=ref([])
// Vditor 实例 // Vditor 实例
const vditor = ref(null); const vditor = ref(null);
const previewHtml = ref(''); const previewHtml = ref('');
const saveStatus = ref('空闲');
let debounceTimer = null;
const initVditor = () => { const initVditor = () => {
vditor.value = new Vditor('vditor', { vditor.value = new Vditor('vditor', {
@@ -257,6 +300,9 @@ const initVditor = () => {
vditor.value.setValue(editData.value.content); vditor.value.setValue(editData.value.content);
} }
}, },
input: (value) => {
debouncedSave(value);
},
upload: { upload: {
accept: 'image/*', accept: 'image/*',
handler(files) { handler(files) {
@@ -438,16 +484,21 @@ const handleImageUpload=async (files) => {
} }
// 在编辑页面按Ctrl+S保存笔记或者点击保存对数据进行保存 // 在编辑页面按Ctrl+S保存笔记或者点击保存对数据进行保存
const handleSave= async (file) => { const handleSave= async (content, isAutoSave = false) => {
imageUrls.value = extractImageUrls(file); imageUrls.value = extractImageUrls(content);
extractDeletedImageUrls(imageUrls.value) extractDeletedImageUrls(imageUrls.value)
editData.value.content = file editData.value.content = content
const filesRes = await updateMarkdown(editData.value); const filesRes = await updateMarkdown(editData.value);
if (filesRes.code===200){ if (filesRes.code===200){
ElMessage.success(filesRes.msg); if (!isAutoSave) {
ElMessage.success(filesRes.msg);
}
await chushihua() await chushihua()
}else { }else {
ElMessage.error(filesRes.msg); if (!isAutoSave) {
ElMessage.error(filesRes.msg);
}
throw new Error(filesRes.msg);
} }
} }
@@ -526,38 +577,83 @@ const deleteNote = async (file) => {
} }
}; };
// ---------------------------------TODO 下面的还没有还是看 const debouncedSave = (content) => {
saveStatus.value = '正在输入...';
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
saveStatus.value = '正在保存...';
try {
// 直接使用 vditor.value.getValue() 获取最新内容
await handleSave(vditor.value.getValue(), true);
saveStatus.value = '已保存';
} catch (error) {
saveStatus.value = '保存失败';
}
}, 2000); // 2-second delay
};
const openRenameDialog = (item, type) => {
itemToRename.value = { ...item, type };
newName.value = type === 'group' ? item.grouping : item.title;
showRenameDialog.value = true;
};
const handleRename = async () => {
if (!newName.value.trim()) {
ElMessage.error('名称不能为空');
return;
}
try {
if (itemToRename.value.type === 'group') {
await updateGroupingName(itemToRename.value.id, newName.value);
ElMessage.success('分类重命名成功');
await fetchGroupings();
} else if (itemToRename.value.type === 'file') {
await updateMarkdownTitle(itemToRename.value.id, newName.value);
ElMessage.success('笔记重命名成功');
selectedFile.value.title = newName.value;
await fetchMarkdownFiles();
}
showRenameDialog.value = false;
} catch (error) {
ElMessage.error('重命名失败: ' + error.message);
}
};
// 上传Markdown文件处理 // 上传Markdown文件处理
const handleMarkdownUpload = (file) => { const handleMarkdownUpload = (file) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = (e) => {
const content = e.target.result; fileToImport.value = {
const fileName = file.name.replace('.md', ''); content: e.target.result,
title: file.name.replace('.md', ''),
try { fileName: file.name,
await axios.post(`${API_BASE_URL}/api/markdown`, content, { };
params: { showSelectGroupDialog.value = true;
groupingId: 1, // 默认放入"全部"分类
title: fileName,
fileName: fileName
},
headers: {
'Content-Type': 'text/plain'
}
});
ElMessage.success('上传成功');
await chushihua()
} catch (error) {
ElMessage.error('上传失败: ' + error.message);
}
}; };
reader.readAsText(file); reader.readAsText(file);
return false; // 阻止默认上传行为 return false; // 阻止默认上传行为
}; };
const confirmImport = async () => {
if (!importGroupId.value) {
ElMessage.error('请选择一个分类');
return;
}
try {
await createMarkdown({
...fileToImport.value,
groupingId: importGroupId.value,
});
ElMessage.success('导入成功');
showSelectGroupDialog.value = false;
await chushihua();
} catch (error) {
ElMessage.error('导入失败: ' + error.message);
}
};
onMounted(() => { onMounted(() => {
chushihua(); chushihua();
// 根据屏幕宽度初始化侧边栏状态 // 根据屏幕宽度初始化侧边栏状态
@@ -596,6 +692,19 @@ const handleSearch = async () => {
ElMessage.error('搜索失败: ' + error.message); ElMessage.error('搜索失败: ' + error.message);
} }
}; };
const handleExportMd = () => {
if (!selectedFile.value) return;
const blob = new Blob([selectedFile.value.content], { type: 'text/markdown;charset=utf-8' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${selectedFile.value.title}.md`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
</script> </script>
<style scoped> <style scoped>
@@ -683,6 +792,35 @@ const handleSearch = async () => {
background: #fff; background: #fff;
} }
.save-status {
margin-left: 10px;
font-size: 14px;
color: #909399;
}
.menu-item-title {
display: flex;
align-items: center;
width: 100%;
justify-content: space-between;
}
.edit-icon {
cursor: pointer;
margin-left: 8px;
display: none;
}
.el-sub-menu__title:hover .edit-icon,
.preview-title:hover .edit-icon {
display: inline-block;
}
.preview-title {
display: flex;
align-items: center;
}
.el-menu-vertical-demo { .el-menu-vertical-demo {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;