From 5ea9c776e78605280d8b0ba10b0a167c05c81679 Mon Sep 17 00:00:00 2001 From: ikmkj Date: Wed, 4 Mar 2026 16:43:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=AC=94=E8=AE=B0=E9=A2=84=E8=A7=88):=20?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E5=A4=A7=E6=96=87=E4=BB=B6=E5=88=86=E5=9D=97?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加分块加载API接口及前端实现,支持大文件(>500KB)的分页加载,提升大文件预览体验 后端实现分块逻辑并添加权限检查,前端添加加载提示和滚动加载功能 --- .../controller/MarkdownController.java | 23 +++ .../bijihoudaun/entity/MarkdownFileChunk.java | 46 +++++ .../service/MarkdownFileService.java | 12 ++ .../service/impl/MarkdownFileServiceImpl.java | 89 +++++++++ biji-qianduan/src/api/CommonApi.js | 4 + biji-qianduan/src/components/HomePage.vue | 169 +++++++++++++++++- .../src/components/home/NotePreview.vue | 30 ++++ 7 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFileChunk.java diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java index ad96ed7..19bf4e1 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java @@ -3,6 +3,7 @@ package com.test.bijihoudaun.controller; import cn.hutool.core.util.ObjectUtil; import com.test.bijihoudaun.common.response.R; import com.test.bijihoudaun.entity.MarkdownFile; +import com.test.bijihoudaun.entity.MarkdownFileChunk; import com.test.bijihoudaun.entity.MarkdownFileVO; import com.test.bijihoudaun.service.MarkdownFileService; import com.test.bijihoudaun.util.SecurityUtil; @@ -117,4 +118,26 @@ public class MarkdownController { return R.success(files); } + @Operation(summary = "分块加载Markdown文件内容", description = "用于大文件(> 500KB)的分页加载") + @Parameters({ + @Parameter(name = "id", description = "文件ID", required = true), + @Parameter(name = "chunkIndex", description = "块索引(从0开始)", required = false), + @Parameter(name = "chunkSize", description = "块大小(字符数),默认10000", required = false) + }) + @GetMapping("/{id}/chunk") + public R getMarkdownChunk( + @PathVariable Long id, + @RequestParam(defaultValue = "0") int chunkIndex, + @RequestParam(defaultValue = "10000") int chunkSize) { + + // 获取当前认证状态 + boolean isAuthenticated = SecurityUtil.isUserAuthenticated(); + + MarkdownFileChunk chunk = markdownFileService.getMarkdownChunk(id, chunkIndex, chunkSize, isAuthenticated); + if (ObjectUtil.isNotNull(chunk)) { + return R.success(chunk); + } + return R.fail("文件未找到"); + } + } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFileChunk.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFileChunk.java new file mode 100644 index 0000000..8a08197 --- /dev/null +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/entity/MarkdownFileChunk.java @@ -0,0 +1,46 @@ +package com.test.bijihoudaun.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * Markdown文件分块加载响应实体 + * 用于大文件分页加载 + */ +@Data +@Schema(name = "Markdown文件分块") +public class MarkdownFileChunk implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(description = "当前块的内容") + private String chunk; + + @Schema(description = "当前块索引(从0开始)") + private int chunkIndex; + + @Schema(description = "总块数") + private int totalChunks; + + @Schema(description = "是否还有更多块") + private boolean hasMore; + + @Schema(description = "笔记ID") + @JsonFormat(shape = JsonFormat.Shape.STRING) + private Long fileId; + + @Schema(description = "笔记标题") + private String title; + + @Schema(description = "是否私密 0-公开 1-私密") + private Integer isPrivate; + + @Schema(description = "总字符数") + private long totalLength; + + @Schema(description = "当前块字符数") + private int chunkLength; +} diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java index 0c9b7b9..ca6cbe9 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java @@ -2,6 +2,7 @@ package com.test.bijihoudaun.service; import com.baomidou.mybatisplus.extension.service.IService; import com.test.bijihoudaun.entity.MarkdownFile; +import com.test.bijihoudaun.entity.MarkdownFileChunk; import com.test.bijihoudaun.entity.MarkdownFileVO; import java.util.List; @@ -66,4 +67,15 @@ public interface MarkdownFileService extends IService { * @return 文件列表 */ List getRecentFiles(int limit); + + /** + * 分块加载Markdown文件内容 + * 用于大文件(> 500KB)的分页加载 + * @param id 文件ID + * @param chunkIndex 块索引(从0开始) + * @param chunkSize 块大小(字符数),默认10000 + * @param isAuthenticated 是否已认证 + * @return 文件块对象 + */ + MarkdownFileChunk getMarkdownChunk(Long id, int chunkIndex, int chunkSize, boolean isAuthenticated); } diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java index b741046..270bf4f 100644 --- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java +++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java @@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.test.bijihoudaun.entity.ImageName; import com.test.bijihoudaun.entity.MarkdownFile; +import com.test.bijihoudaun.entity.MarkdownFileChunk; import com.test.bijihoudaun.entity.MarkdownFileVO; import com.test.bijihoudaun.mapper.ImageNameMapper; import com.test.bijihoudaun.mapper.MarkdownFileMapper; @@ -156,4 +157,92 @@ public class MarkdownFileServiceImpl return markdownFileMapper.selectRecentWithGrouping(limit); } + @Override + public MarkdownFileChunk getMarkdownChunk(Long id, int chunkIndex, int chunkSize, boolean isAuthenticated) { + // 获取文件基本信息(不包含内容) + MarkdownFileVO fileVO = markdownFileMapper.selectByIdWithGrouping(id); + if (fileVO == null) { + return null; + } + + // 检查权限:私密笔记需要登录 + if (fileVO.getIsPrivate() != null && fileVO.getIsPrivate() == 1 && !isAuthenticated) { + MarkdownFileChunk chunk = new MarkdownFileChunk(); + chunk.setFileId(id); + chunk.setTitle(fileVO.getTitle()); + chunk.setIsPrivate(fileVO.getIsPrivate()); + chunk.setChunk("该笔记为私密笔记,请登录后查看"); + chunk.setChunkIndex(0); + chunk.setTotalChunks(1); + chunk.setHasMore(false); + chunk.setTotalLength(0); + chunk.setChunkLength(0); + return chunk; + } + + // 获取完整内容 + MarkdownFile file = this.getById(id); + if (file == null || file.getContent() == null) { + return null; + } + + String fullContent = file.getContent(); + int totalLength = fullContent.length(); + + // 如果文件小于 500KB(约50万字符),直接返回全部内容 + if (totalLength <= 500000) { + MarkdownFileChunk chunk = new MarkdownFileChunk(); + chunk.setFileId(id); + chunk.setTitle(file.getTitle()); + chunk.setIsPrivate(file.getIsPrivate()); + chunk.setChunk(fullContent); + chunk.setChunkIndex(0); + chunk.setTotalChunks(1); + chunk.setHasMore(false); + chunk.setTotalLength(totalLength); + chunk.setChunkLength(totalLength); + return chunk; + } + + // 大文件分页加载 + // 计算总块数 + int totalChunks = (int) Math.ceil((double) totalLength / chunkSize); + + // 确保 chunkIndex 在有效范围内 + if (chunkIndex < 0) { + chunkIndex = 0; + } + if (chunkIndex >= totalChunks) { + chunkIndex = totalChunks - 1; + } + + // 计算当前块的起止位置 + int start = chunkIndex * chunkSize; + int end = Math.min(start + chunkSize, totalLength); + + // 截取内容 + String chunkContent = fullContent.substring(start, end); + + // 如果不是第一块,尝试从完整的换行处开始 + if (chunkIndex > 0 && start < totalLength) { + // 找到第一个换行符,从下一行开始显示 + int firstNewLine = chunkContent.indexOf('\n'); + if (firstNewLine > 0) { + chunkContent = chunkContent.substring(firstNewLine + 1); + } + } + + MarkdownFileChunk chunk = new MarkdownFileChunk(); + chunk.setFileId(id); + chunk.setTitle(file.getTitle()); + chunk.setIsPrivate(file.getIsPrivate()); + chunk.setChunk(chunkContent); + chunk.setChunkIndex(chunkIndex); + chunk.setTotalChunks(totalChunks); + chunk.setHasMore(chunkIndex < totalChunks - 1); + chunk.setTotalLength(totalLength); + chunk.setChunkLength(chunkContent.length()); + + return chunk; + } } diff --git a/biji-qianduan/src/api/CommonApi.js b/biji-qianduan/src/api/CommonApi.js index 7c11dec..8bee516 100644 --- a/biji-qianduan/src/api/CommonApi.js +++ b/biji-qianduan/src/api/CommonApi.js @@ -16,6 +16,10 @@ export const markdownAll = () => axiosApi.get(`/api/markdown`); // 预览markdown文件 export const Preview = (id) => axiosApi.get(`/api/markdown/${id}`); +// 分块加载markdown文件内容(用于大文件) +export const PreviewChunk = (id, chunkIndex = 0, chunkSize = 10000) => + axiosApi.get(`/api/markdown/${id}/chunk?chunkIndex=${chunkIndex}&chunkSize=${chunkSize}`); + // 创建分类分组 export const addGroupings = (group) => { return axiosApi.post('/api/groupings', group); diff --git a/biji-qianduan/src/components/HomePage.vue b/biji-qianduan/src/components/HomePage.vue index 4081b8c..93a5b31 100644 --- a/biji-qianduan/src/components/HomePage.vue +++ b/biji-qianduan/src/components/HomePage.vue @@ -33,6 +33,7 @@ :file="selectedFile" :is-mobile="isMobile" :is-user-logged-in="userStore.isLoggedIn" + :has-more-chunks="hasMoreChunks" @back="selectedFile = null" @edit="editNote(selectedFile)" @delete="deleteNote(selectedFile)" @@ -66,8 +67,11 @@ 加载中... -
- 加载更多 +
+ 继续滚动加载更多... +
+
+ 没有更多笔记了
@@ -140,6 +144,7 @@ import { groupingAll, markdownList, Preview, + PreviewChunk, updateMarkdown, searchMarkdown, deleteMarkdown, @@ -180,6 +185,7 @@ const displayedFiles = ref([]); const currentPage = ref(0); const pageSize = ref(16); const isLoadingMore = ref(false); +const loadCount = ref(0); // 记录加载次数,用于动态增加加载数量 const noteListWrapper = ref(null); const showEditor = ref(false); const selectedFile = ref(null); @@ -203,6 +209,12 @@ const vditorReady = ref(false); // 提供给子组件使用 provide('vditorReady', vditorReady); +// 大文件分块加载状态 +const currentChunkIndex = ref(0); +const hasMoreChunks = ref(false); +const isLoadingChunk = ref(false); +const totalChunks = ref(0); + // Data for dialogs const itemToRename = ref(null); const fileToImport = ref(null); @@ -258,6 +270,7 @@ const resetToHomeView = async () => { searchKeyword.value = ''; activeMenu.value = 'all'; currentPage.value = 0; + loadCount.value = 0; // 重置加载计数 try { groupMarkdownFiles.value = await getRecentFiles(100) || []; updateDisplayedFiles(); @@ -269,9 +282,21 @@ const resetToHomeView = async () => { } }; +// 计算动态加载数量:基础16个,每次增加8个,最多48个 +const getDynamicPageSize = () => { + const baseSize = 16; + const increment = 8; + const maxSize = 48; + return Math.min(baseSize + loadCount.value * increment, maxSize); +}; + const updateDisplayedFiles = () => { const start = 0; - const end = (currentPage.value + 1) * pageSize.value; + const dynamicPageSize = getDynamicPageSize(); + // 计算实际显示数量:首次16个,之后动态增加 + const end = displayedFiles.value.length === 0 + ? dynamicPageSize + : displayedFiles.value.length + dynamicPageSize; displayedFiles.value = groupMarkdownFiles.value.slice(start, end); }; @@ -280,6 +305,7 @@ const loadMoreFiles = () => { isLoadingMore.value = true; setTimeout(() => { + loadCount.value++; currentPage.value++; updateDisplayedFiles(); isLoadingMore.value = false; @@ -288,11 +314,21 @@ const loadMoreFiles = () => { const handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; - if (scrollHeight - scrollTop - clientHeight < 100 && hasMoreFiles.value && !isLoadingMore.value) { + // 距离底部 200px 时自动加载,给用户更流畅的体验 + if (scrollHeight - scrollTop - clientHeight < 200 && hasMoreFiles.value && !isLoadingMore.value) { loadMoreFiles(); } }; +// 预览区域滚动处理(用于大文件分块加载) +const handlePreviewScroll = (e) => { + const { scrollTop, scrollHeight, clientHeight } = e.target; + // 距离底部 300px 时加载下一页内容 + if (scrollHeight - scrollTop - clientHeight < 300 && hasMoreChunks.value && !isLoadingChunk.value) { + loadMoreContent(); + } +}; + // Event Handlers from Components const handleSelectFile = async (data) => { resetEdit(); @@ -300,6 +336,7 @@ const handleSelectFile = async (data) => { const files = await markdownList(data.id); groupMarkdownFiles.value = files || []; currentPage.value = 0; + loadCount.value = 0; // 重置加载计数 updateDisplayedFiles(); selectedFile.value = null; showEditor.value = false; @@ -377,26 +414,71 @@ const previewFile = async (file) => { } // 重置渲染缓存,确保每次打开笔记都重新渲染 lastRenderedKey = null; + // 重置分块加载状态 + currentChunkIndex.value = 0; + hasMoreChunks.value = false; + isLoadingChunk.value = false; + totalChunks.value = 0; + // 先立即显示预览页(加载状态),让用户感知到响应 selectedFile.value = { ...file, content: '', isLoading: true, isRendering: false }; showEditor.value = false; - // 异步加载内容 + // 异步加载内容(使用分块加载) + await loadNoteChunk(file.id, 0); +}; + +// 分块加载笔记内容 +const loadNoteChunk = async (fileId, chunkIndex) => { + if (isLoadingChunk.value) return; + + isLoadingChunk.value = true; try { - const content = await Preview(file.id) || ''; - // 内容加载完成后更新 - if (selectedFile.value && selectedFile.value.id === file.id) { + const chunkData = await PreviewChunk(fileId, chunkIndex, 10000); + + if (selectedFile.value && selectedFile.value.id === fileId) { + // 更新分块加载状态 + currentChunkIndex.value = chunkData.chunkIndex; + hasMoreChunks.value = chunkData.hasMore; + totalChunks.value = chunkData.totalChunks; + + // 如果是第一块,直接设置内容;否则追加内容 + let newContent; + if (chunkIndex === 0) { + newContent = chunkData.chunk; + } else { + newContent = selectedFile.value.content + chunkData.chunk; + } + // 如果 Vditor 渲染引擎未就绪,显示渲染中状态 const isRendering = !vditorReady.value; - selectedFile.value = { ...file, content, isLoading: false, isRendering }; + + selectedFile.value = { + ...selectedFile.value, + content: newContent, + isLoading: false, + isRendering, + title: chunkData.title || selectedFile.value.title, + isPrivate: chunkData.isPrivate !== undefined ? chunkData.isPrivate : selectedFile.value.isPrivate + }; } } catch (error) { // 错误已在 axios 拦截器中显示,这里不再重复显示 console.error('获取笔记内容失败:', error); selectedFile.value = null; + } finally { + isLoadingChunk.value = false; } }; +// 加载更多内容(滚动到底部时调用) +const loadMoreContent = async () => { + if (!selectedFile.value || !hasMoreChunks.value || isLoadingChunk.value) return; + + const nextChunkIndex = currentChunkIndex.value + 1; + await loadNoteChunk(selectedFile.value.id, nextChunkIndex); +}; + const editNote = (file) => { editData.value = { ...file }; showEditor.value = true; @@ -474,6 +556,7 @@ const handleSearch = async () => { try { groupMarkdownFiles.value = await searchMarkdown(searchKeyword.value) || []; currentPage.value = 0; + loadCount.value = 0; // 重置加载计数 updateDisplayedFiles(); selectedFile.value = null; showEditor.value = false; @@ -669,6 +752,10 @@ const renderMarkdown = async (file) => { img.setAttribute('loading', 'lazy'); } }); + // 添加滚动监听,用于大文件分块加载 + if (hasMoreChunks.value) { + previewElement.addEventListener('scroll', handlePreviewScroll); + } } }); lastRenderedKey = renderKey; @@ -776,4 +863,68 @@ watch(showEditor, (isEditor) => { justify-content: center; padding: 20px; } + +/* 没有更多笔记了 - 美化样式 */ +.no-more-hint { + display: flex; + justify-content: center; + align-items: center; + padding: 30px 20px; + margin: 10px 0; +} + +.no-more-hint span { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%); + border-radius: 24px; + color: #909399; + font-size: 14px; + font-weight: 500; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + border: 1px solid #ebeef5; +} + +.no-more-hint span::before { + content: ''; + display: inline-block; + width: 4px; + height: 4px; + background-color: #c0c4cc; + border-radius: 50%; +} + +.no-more-hint span::after { + content: ''; + display: inline-block; + width: 4px; + height: 4px; + background-color: #c0c4cc; + border-radius: 50%; +} + +/* 暗黑模式适配 */ +.dark-theme .no-more-hint span { + background: linear-gradient(135deg, #2c2c3d 0%, #1e1e2f 100%); + color: #606266; + border-color: #2c2c3d; +} + +.dark-theme .no-more-hint span::before, +.dark-theme .no-more-hint span::after { + background-color: #606266; +} + +/* 继续滚动加载更多提示 */ +.load-more-hint { + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + color: var(--el-color-primary); + font-size: 14px; + font-weight: 500; +} \ No newline at end of file diff --git a/biji-qianduan/src/components/home/NotePreview.vue b/biji-qianduan/src/components/home/NotePreview.vue index 488e74d..aa1ca7f 100644 --- a/biji-qianduan/src/components/home/NotePreview.vue +++ b/biji-qianduan/src/components/home/NotePreview.vue @@ -51,6 +51,11 @@ 正在渲染... + +
+ + 加载更多内容... +
@@ -64,6 +69,10 @@ const props = defineProps({ }, isMobile: Boolean, isUserLoggedIn: Boolean, + hasMoreChunks: { + type: Boolean, + default: false + } }); const emit = defineEmits([ @@ -192,4 +201,25 @@ const handleExport = (format) => { font-size: 14px; color: var(--text-color-secondary); } + +/* 分块加载提示 */ +.chunk-loading-hint { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 20px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + color: var(--el-color-primary); + font-size: 14px; +} + +.dark-theme .chunk-loading-hint { + background-color: rgba(30, 30, 47, 0.9); +}